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.

Ferramenta:Godot

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).

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot 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 Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 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.

Passo 6: O Ciclo de Ondas (Scatter e Chase) com Timer

No arcade original, os fantasmas não perseguem o tempo todo. Eles alternam entre SCATTER (recuam para o canto deles) e CHASE (caçam) em ondas de duração fixa, e é esse vai e vem que cria o respiro: você aprende a usar os segundos de SCATTER para limpar os corredores perigosos. Sem esse ciclo, quatro fantasmas caçando sem parar sufocam o jogador e o jogo fica injusto.

O ponto de projeto importante é que a onda é um estado global, não de cada fantasma. Um único relógio decide a onda atual e avisa todos os fantasmas de uma vez. Um fantasma em modo FRIGHTENED ignora o aviso até o medo passar, porque fuga tem prioridade sobre a onda. Isso evita o bug clássico de um fantasma voltar a caçar no meio do power pellet.

Centralize o relógio no GameManager, lendo os tempos de uma tabela. Os valores abaixo são próximos dos ciclos do arcade, em segundos:

# Pares (estado, duração em segundos). O último CHASE é "infinito".
var ondas: Array[Dictionary] = [
    { "estado": Ghost.State.SCATTER, "tempo": 7.0 },
    { "estado": Ghost.State.CHASE, "tempo": 20.0 },
    { "estado": Ghost.State.SCATTER, "tempo": 7.0 },
    { "estado": Ghost.State.CHASE, "tempo": 20.0 },
    { "estado": Ghost.State.SCATTER, "tempo": 5.0 },
    { "estado": Ghost.State.CHASE, "tempo": -1.0 },
]

var onda_atual: int = 0
var tempo_onda: float = 0.0

func _process(delta: float) -> void:
    _atualizar_ondas(delta)

func _atualizar_ondas(delta: float) -> void:
    var onda: Dictionary = ondas[onda_atual]
    var duracao: float = onda["tempo"]
    if duracao < 0.0:
        return  # CHASE final nunca expira

    tempo_onda += delta
    if tempo_onda >= duracao:
        tempo_onda = 0.0
        onda_atual = min(onda_atual + 1, ondas.size() - 1)
        _aplicar_onda(ondas[onda_atual]["estado"])

func _aplicar_onda(estado: int) -> void:
    for node in get_tree().get_nodes_in_group("ghosts"):
        var ghost: Ghost = node as Ghost
        ghost.definir_onda(estado)

No Ghost, o aviso só troca o estado se ele não estiver fugindo. Adicione um método que respeita essa regra e, de quebra, inverte a direção na virada de onda, que é outro detalhe fiel ao original:

func definir_onda(novo_estado: int) -> void:
    if state == State.FRIGHTENED:
        return  # medo tem prioridade sobre a onda global
    if state != novo_estado:
        direction = -direction  # vira na troca de onda
    state = novo_estado

Guarde a onda que estava ativa antes do medo para o fantasma voltar a ela quando o power pellet acabar, em vez de cair sempre em CHASE. Basta salvar state dentro de frighten antes de trocar para FRIGHTENED e restaurar esse valor em _end_frightened. Com o relógio central e o respeito ao medo, você tem o ritmo de pressão completo do arcade.

Esse padrão de um estado global que coordena vários agentes, com exceções locais, é a mesma ideia por trás de uma máquina de estados (FSM) na Godot, e vale estudar a versão organizada dela quando os estados do seu jogo passarem de três ou quatro.

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.