Voltar para o Blog
Quest Log

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

Labirinto neon estilo arcade com pellets brilhantes e fantasmas coloridos representando um clone de Pac-Man feito na Godot

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:

  1. local_to_map e map_to_local fazem toda a conversão entre pixels e células. Nada de matemática manual de grade.
  2. O buffer só é consumido quando a curva é possível. Aperte "cima" antes do cruzamento e o personagem vira sozinho na hora certa.
  3. 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).

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

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.x sair do mapa.
  • HUD de verdade: troque os print por Label com pontuação e vidas.
  • Animações: AnimatedSprite2D para a boca do jogador e os olhos dos fantasmas seguindo direction.
  • 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.