Como Fazer um Jogo Estilo Pac-Man na Godot (Passo a Passo)

Aprenda a fazer um jogo estilo Pac-Man na Godot 4: labirinto com TileMapLayer, movimento em grade, fantasmas com estados e power pellets, em GDScript tipado.
Fazer um jogo estilo Pac-Man na Godot 4 é um dos exercícios mais completos que um iniciante pode encarar: em um único projeto você pratica movimento em grade, leitura de tiles, sinais, máquina de estados e uma IA de perseguição que parece muito mais esperta do que o código que a implementa. Neste tutorial montamos um clone funcional do zero, com todo o código em GDScript tipado, do labirinto ao power pellet que vira o jogo de cabeça para baixo.
Por Que um Jogo Estilo Pac-Man na Godot Ensina Tanto
O Pac-Man original (1980) esconde três lições de design que continuam atuais:
- Movimento em grade com input antecipado. O jogador não anda livre pelo mapa: ele anda de célula em célula, e o jogo guarda a próxima curva que você pediu antes de ela ser possível. É esse buffer que faz o controle parecer perfeito.
- IA por alvos, não por inteligência. Cada fantasma roda o mesmo algoritmo bobo (na próxima esquina, vire na direção que aproxima do alvo). O que muda entre eles é apenas qual célula é o alvo. Blinky mira o jogador, Pinky mira 4 células à frente do jogador. Alvos diferentes criam a ilusão de emboscada coordenada.
- Estados globais de jogo. Perseguir, espalhar, fugir. Um enum e um timer transformam a mesma cena em três experiências diferentes.
Se você já brincou com outro clássico, como no nosso passo a passo de como fazer um Tetris na Godot, vai notar que a lógica de grade é parente próxima. Aqui ela ganha movimento contínuo por cima.
Passo 1: A Cena e o Labirinto com TileMapLayer
Crie uma cena Main com um Node2D na raiz e adicione um nó TileMapLayer chamado Walls. Na Godot 4.3+ o TileMapLayer substitui o antigo TileMap com camadas internas: cada camada agora é um nó próprio, o que simplifica muito o código.
Configure um TileSet com células de 16x16 pixels e pinte o labirinto: corredores vazios, paredes com tile. A regra do jogo inteiro será uma só:
Se a célula tem tile na camada
Walls, é parede. Se não tem, é corredor.
Isso nos dá uma função de colisão de uma linha, sem física nenhuma:
func _is_free(cell: Vector2i) -> bool:
return walls.get_cell_source_id(cell) == -1
get_cell_source_id devolve -1 quando a célula está vazia. Mantenha o labirinto fechado nas bordas para ninguém escapar da grade.
Estrutura sugerida da cena principal:
Main (Node2D)
├── Walls (TileMapLayer)
├── Pellets (Node2D, container dos pellets)
├── Player (Node2D)
│ └── Eater (Area2D + CollisionShape2D pequena)
├── Ghosts (Node2D, container dos fantasmas)
└── GameManager (Node)
Passo 2: Movimento do Jogador em Grade com Fila de Direção
Aqui mora o truque clássico do Pac-Man. O jogador se move célula a célula, mas o input é lido o tempo todo e guardado em buffered_direction. A cada vez que ele chega ao centro de uma célula, o código tenta primeiro a curva guardada; se a parede ainda bloqueia, segue em frente na direção atual.
Script do Player (Node2D):
extends Node2D
class_name Player
@export var walls: TileMapLayer
@export var speed: float = 80.0
var current_cell: Vector2i = Vector2i.ZERO
var direction: Vector2i = Vector2i.ZERO
var buffered_direction: Vector2i = Vector2i.ZERO
var target_position: Vector2 = Vector2.ZERO
func _ready() -> void:
current_cell = walls.local_to_map(position)
position = walls.map_to_local(current_cell)
target_position = position
func _process(delta: float) -> void:
_read_input()
_move(delta)
func _read_input() -> void:
if Input.is_action_just_pressed("ui_up"):
buffered_direction = Vector2i.UP
elif Input.is_action_just_pressed("ui_down"):
buffered_direction = Vector2i.DOWN
elif Input.is_action_just_pressed("ui_left"):
buffered_direction = Vector2i.LEFT
elif Input.is_action_just_pressed("ui_right"):
buffered_direction = Vector2i.RIGHT
func _move(delta: float) -> void:
position = position.move_toward(target_position, speed * delta)
if position.distance_to(target_position) > 0.5:
return
position = target_position
current_cell = walls.local_to_map(position)
if buffered_direction != Vector2i.ZERO and _is_free(current_cell + buffered_direction):
direction = buffered_direction
buffered_direction = Vector2i.ZERO
if direction != Vector2i.ZERO and _is_free(current_cell + direction):
target_position = walls.map_to_local(current_cell + direction)
func _is_free(cell: Vector2i) -> bool:
return walls.get_cell_source_id(cell) == -1
Repare em três detalhes:
local_to_mapemap_to_localfazem toda a conversão entre pixels e células. Nada de matemática manual de grade.- O buffer só é consumido quando a curva é possível. Aperte "cima" antes do cruzamento e o personagem vira sozinho na hora certa.
- Se a direção atual bate em parede e não há buffer válido, o personagem simplesmente para no centro da célula, exatamente como no arcade.
Este esquema de mover até o centro da próxima célula é a versão mínima de um tema maior; se quiser entender variações com Tween, snapping e grades isométricas, temos um guia dedicado de movimento em grade na Godot.
Passo 3: Pellets com Area2D e Contagem para a Vitória
Cada pellet é uma cena mínima: Area2D com um Sprite2D e uma CollisionShape2D de raio pequeno. Adicione a cena ao grupo pellets (aba Node > Groups) e use este script:
extends Area2D
class_name Pellet
signal eaten
func _ready() -> void:
area_entered.connect(_on_area_entered)
func _on_area_entered(_area: Area2D) -> void:
eaten.emit()
queue_free()
A conexão area_entered.connect(_on_area_entered) usa o estilo Godot 4 com Callable, sem strings mágicas. O Eater (Area2D filha do Player) é quem dispara o sinal ao encostar.
O GameManager conta os pellets no início e declara vitória quando o contador zera:
extends Node
class_name GameManager
var pellets_left: int = 0
var score: int = 0
func _ready() -> void:
var pellets: Array[Node] = get_tree().get_nodes_in_group("pellets")
pellets_left = pellets.size()
for node in pellets:
var pellet: Pellet = node as Pellet
pellet.eaten.connect(_on_pellet_eaten)
func _on_pellet_eaten() -> void:
score += 10
pellets_left -= 1
if pellets_left == 0:
print("Vitória! Pontuação final: %d" % score)
get_tree().paused = true
Espalhe os pellets pelos corredores (duplicar com Ctrl+D e arrastar funciona; um script editor que preenche células livres é o upgrade natural depois).
Passo 4: Fantasmas com Estados e Alvos Diferentes
Agora a parte que parece difícil e não é. O fantasma usa o mesmo movimento em grade do jogador, com duas diferenças: ele nunca inverte a direção espontaneamente, e a cada centro de célula ele escolhe, entre as saídas livres, a que mais aproxima do alvo atual. O alvo depende do estado:
- CHASE (perseguir): alvo definido pela "personalidade" do fantasma.
- SCATTER (espalhar): alvo é um canto fixo do mapa, cada fantasma tem o seu. É o respiro que o arcade dá ao jogador em ciclos.
- FRIGHTENED (fugir): alvo aleatório, movimento errático.
Script base do Ghost (Node2D):
extends Node2D
class_name Ghost
enum State { SCATTER, CHASE, FRIGHTENED }
@export var walls: TileMapLayer
@export var player: Player
@export var speed: float = 70.0
@export var scatter_corner: Vector2i = Vector2i(1, 1)
var state: State = State.SCATTER
var direction: Vector2i = Vector2i.LEFT
var current_cell: Vector2i = Vector2i.ZERO
var target_position: Vector2 = Vector2.ZERO
var start_cell: Vector2i = Vector2i.ZERO
func _ready() -> void:
current_cell = walls.local_to_map(position)
start_cell = current_cell
position = walls.map_to_local(current_cell)
target_position = position
func _process(delta: float) -> void:
position = position.move_toward(target_position, speed * delta)
if position.distance_to(target_position) > 0.5:
return
position = target_position
current_cell = walls.local_to_map(position)
_choose_direction()
target_position = walls.map_to_local(current_cell + direction)
func _choose_direction() -> void:
var options: Array[Vector2i] = [Vector2i.UP, Vector2i.LEFT, Vector2i.DOWN, Vector2i.RIGHT]
var target: Vector2i = _target_cell()
var best: Vector2i = Vector2i.ZERO
var best_distance: float = INF
for option in options:
if option == -direction:
continue
var next_cell: Vector2i = current_cell + option
if not _is_free(next_cell):
continue
var distance: float = Vector2(next_cell).distance_to(Vector2(target))
if distance < best_distance:
best_distance = distance
best = option
if best == Vector2i.ZERO:
best = -direction
direction = best
func _target_cell() -> Vector2i:
match state:
State.CHASE:
return _chase_target()
State.FRIGHTENED:
return Vector2i(randi_range(0, 27), randi_range(0, 30))
_:
return scatter_corner
return scatter_corner
func _chase_target() -> Vector2i:
return walls.local_to_map(player.position)
func _is_free(cell: Vector2i) -> bool:
return walls.get_cell_source_id(cell) == -1
Esse _chase_target padrão é exatamente o Blinky do arcade original: alvo é a célula do jogador, perseguição direta. Para criar o Pinky, que arma emboscadas mirando 4 células à frente da direção do jogador, basta uma subclasse que sobrescreve um único método:
extends Ghost
class_name Pinky
func _chase_target() -> Vector2i:
return walls.local_to_map(player.position) + player.direction * 4
Duas linhas de diferença, comportamento visivelmente distinto. Essa é a grande sacada do Pac-Man real: os quatro fantasmas rodam o mesmo código de decisão, só trocam o alvo. Você não precisa reproduzir Inky e Clyde com fidelidade; invente seus próprios alvos (um canto atrás do jogador, o pellet mais próximo) e o jogo já ganha personalidade.
A escolha gulosa por distância euclidiana funciona bem em labirintos clássicos, que não têm becos longos. Se quiser fantasmas que calculam a rota inteira em mapas mais complexos, o passo seguinte é o pathfinding com A* em grade na Godot, que se encaixa direto nesta mesma estrutura de células.
Passo 5: Power Pellet, Modo Fugir e Comer Fantasma
O power pellet reaproveita a cena do pellet comum, maior e com um sinal extra. Coloque os fantasmas no grupo ghosts e adicione ao GameManager:
func activate_power_mode() -> void:
for node in get_tree().get_nodes_in_group("ghosts"):
var ghost: Ghost = node as Ghost
ghost.frighten(6.0)
No power pellet, conecte a coleta a esse método (via sinal, como no pellet comum, ou chamando o manager exportado). No Ghost, o modo de fuga é um estado com prazo de validade:
func frighten(duration: float) -> void:
state = State.FRIGHTENED
direction = -direction
modulate = Color(0.5, 0.5, 1.0)
get_tree().create_timer(duration).timeout.connect(_end_frightened)
func _end_frightened() -> void:
if state == State.FRIGHTENED:
state = State.CHASE
modulate = Color.WHITE
func eaten_by_player() -> void:
current_cell = start_cell
position = walls.map_to_local(start_cell)
target_position = position
state = State.SCATTER
modulate = Color.WHITE
Detalhes fiéis ao arcade: ao entrar no modo frightened o fantasma inverte a direção na hora (o único momento em que isso acontece) e fica azulado via modulate. Reduza speed nesse estado se quiser caprichar.
Falta o contato entre jogador e fantasma. Dê ao fantasma uma Area2D filha (como o Eater do jogador) e trate a colisão no script do fantasma:
func _on_body_area_entered(area: Area2D) -> void:
if not area.is_in_group("player_eater"):
return
if state == State.FRIGHTENED:
eaten_by_player()
else:
get_tree().reload_current_scene()
Coloque o Eater do jogador no grupo player_eater. Fantasma assustado é comido e volta para a base; fantasma normal encosta e a rodada reinicia (troque o reload_current_scene por um sistema de vidas quando quiser evoluir).
Para o ciclo clássico de alternância, adicione um Timer que troca todos os fantasmas entre SCATTER e CHASE a cada 7 e 20 segundos. É esse vai e vem que cria as "ondas" de pressão do jogo original.
Próximos Passos
Com o loop principal fechado (comer todos os pellets sem ser pego, com o power pellet como ferramenta de contra-ataque), o projeto vira um playground de melhorias:
- Túnel lateral: teleporte o jogador de uma borda à outra quando
current_cell.xsair do mapa. - HUD de verdade: troque os
printporLabelcom pontuação e vidas. - Animações:
AnimatedSprite2Dpara a boca do jogador e os olhos dos fantasmas seguindodirection. - Frutas bônus: uma Area2D temporária que aparece após X pellets comidos.
- IA melhor: os dois fantasmas restantes do arcade, ou fuga calculada com A*.
O padrão que você levou daqui (grade + estados + alvos) reaparece em roguelikes, tower defense e puzzle games. No CursoGame.Dev a gente defende exatamente isso: clonar clássicos com as próprias mãos é o caminho mais curto para entender por que eles funcionam. Escolha o próximo da lista e repita o processo.
Perguntas frequentes
Preciso reproduzir a IA original dos 4 fantasmas do Pac-Man?
Não. O que vale a pena copiar é a ideia central: cada fantasma persegue um alvo diferente, e é isso que cria a sensação de cerco. Com um método virtual que devolve a célula alvo, você implementa Blinky e Pinky em poucas linhas e já tem 80% da experiência. Os comportamentos exatos de Inky e Clyde são refinamento opcional.
Devo usar CharacterBody2D ou Node2D para o jogador no estilo Pac-Man?
Para movimento travado em grade, Node2D com movimento manual até o centro da próxima célula é mais simples e mais previsível. CharacterBody2D brilha quando você precisa de física de colisão contínua, o que não é o caso aqui: as paredes são checadas por célula no TileMapLayer. Menos física envolvida significa menos bugs de canto.
Como funciona o buffer de direção do Pac-Man?
Quando o jogador aperta uma direção que ainda está bloqueada por parede, o jogo guarda essa intenção em uma variável em vez de descartá-la. A cada centro de célula, o código tenta primeiro a direção guardada; se o caminho abriu, a curva acontece sozinha. É esse detalhe que faz o controle parecer responsivo mesmo em corredores apertados.
Como detectar que o jogador comeu um pellet na Godot 4?
Cada pellet é uma Area2D pequena que conecta o sinal area_entered a um método local. O jogador carrega uma Area2D filha que serve de "boca". Quando as duas se sobrepõem, o pellet emite um sinal próprio, avisa o gerenciador de jogo e chama queue_free. O gerenciador só precisa contar quantos faltam para declarar vitória.
O modo de fuga dos fantasmas precisa de pathfinding A*?
Não para começar. No modo frightened basta trocar o alvo por uma célula aleatória do labirinto e manter a mesma regra de decisão por distância, que os fantasmas já vagam de forma convincente. A* passa a valer a pena se você quiser fuga inteligente de verdade ou labirintos com becos complexos, e aí o mesmo grid que você já montou serve de base.


