Dungeon Procedural na Godot 4: Gere Mapas Aleatórios em GDScript

Tutorial completo de dungeon procedural na Godot 4: random walk, salas e corredores, TileMapLayer e seed em GDScript tipado, com código pronto pra usar.
Dungeon Procedural na Godot 4: Gere Mapas Aleatórios em GDScript
Se você já jogou The Binding of Isaac, Hades ou qualquer roguelike da vida, já viu geração procedural de dungeon em ação: a cada partida, um mapa novo, mas que sempre parece feito de propósito. Neste tutorial a gente implementa geração procedural de dungeon na Godot 4 do zero, em GDScript tipado, usando duas técnicas clássicas: random walk pra cavernas orgânicas e salas com corredores pra dungeons estruturadas. No final você vai ter um gerador que pinta um TileMapLayer, posiciona inimigos longe do jogador e ainda aceita seed fixa pra reproduzir o mesmo mapa quando precisar.
O que é geração procedural de dungeon (e por que roguelike vive disso)
Geração procedural é criar conteúdo por código seguindo regras, em vez de montar tudo na mão no editor. No caso de dungeons, o algoritmo decide onde ficam salas, corredores, paredes, inimigos e itens a cada execução.
O motivo de como fazer um roguelike passar quase sempre por esse assunto é simples: rejogabilidade. Um mapa desenhado à mão é ótimo na primeira vez e decorável na quinta. Um gerador com boas regras entrega um layout inédito a cada run, e o jogador volta porque a exploração nunca se repete. É conteúdo infinito, mas não é conteúdo aleatório de verdade: as regras garantem que o mapa continua jogável (tudo conectado, saída alcançável, dificuldade distribuída).
Tem um bônus prático pra dev solo: você troca semanas desenhando fases por dias ajustando um algoritmo. O custo é que debugar geração exige disciplina, e é aí que a seed entra, como você vai ver já já.
Random walk ou salas e corredores: qual abordagem escolher
As duas técnicas mais acessíveis pra quem está começando são estas:
Random walk (caminhada aleatória): um "caminhante" parte de um ponto e anda em direções aleatórias, marcando cada célula visitada como chão. O resultado é uma caverna orgânica, cheia de reentrâncias, sem salas definidas. Use quando o clima do jogo pede algo natural: cavernas, minas, florestas subterrâneas. Jogos de escavação e exploração se dão bem com isso.
Salas + corredores: o algoritmo sorteia retângulos (as salas), rejeita os que se sobrepõem e liga os centros com corredores em L. O resultado é a dungeon clássica: cômodos bem definidos conectados por passagens. Use quando o design precisa de estrutura: uma sala de tesouro, uma sala de chefe, combate por cômodo no estilo Isaac.
Regra de bolso: se o jogador deve sentir que está num lugar construído por alguém, vá de salas e corredores. Se deve sentir que está num lugar que a natureza cavou, vá de random walk. Nada impede de misturar os dois depois (salas ligadas por trechos de caverna, por exemplo).
Setup da geração procedural de dungeon na Godot 4
Antes do algoritmo, duas decisões de fundação.
TileMapLayer, não TileMap. Desde a Godot 4.3, o nó TileMap está obsoleto e cada camada de tiles virou um nó próprio chamado TileMapLayer. Se você está seguindo tutorial antigo que usa TileMap, adapte: a API de pintura é praticamente a mesma, com set_cell(coords, source_id, atlas_coords).
RandomNumberGenerator com seed. Em vez das funções globais tipo randi(), a gente usa uma instância de RandomNumberGenerator. O motivo: controle da seed. Mesma seed, mesmo mapa, sempre. Isso transforma "apareceu um mapa quebrado ontem e não sei reproduzir" em "roda a seed 42 e olha a sala 3". Também é o que viabiliza desafio diário, onde todo mundo joga o mesmo layout no mesmo dia. Se esse assunto é novo pra você, vale ler antes sobre aleatoriedade e RNG na Godot.
Monte uma cena com um Node2D na raiz e um TileMapLayer filho chamado Chao, com um TileSet configurado (pelo menos um tile de chão no atlas). O esqueleto do script fica assim:
extends Node2D
const LARGURA: int = 60
const ALTURA: int = 40
@onready var chao: TileMapLayer = $Chao
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
var celulas_chao: Dictionary = {}
func _ready() -> void:
rng.seed = hash("dungeon-teste-01") # seed fixa enquanto desenvolve
gerar_dungeon(LARGURA, ALTURA)
pintar_mapa()
func gerar_dungeon(largura: int, altura: int) -> void:
celulas_chao.clear()
gerar_random_walk(largura, altura)
func pintar_mapa() -> void:
chao.clear()
for celula: Vector2i in celulas_chao.keys():
chao.set_cell(celula, 0, Vector2i(0, 0))
O padrão é sempre o mesmo: o algoritmo escreve células num Dictionary (a chave é um Vector2i, o valor é só um true), e uma função separada pinta o TileMapLayer. Separar geração de pintura facilita testar o algoritmo sem cena e trocar o visual sem tocar na lógica. No set_cell, o segundo argumento é o source_id da fonte no TileSet e o terceiro é a coordenada do tile dentro do atlas; ajuste os dois pro seu TileSet.
Quando o jogo estiver pronto pra produção, troque a seed fixa por rng.randomize() pra ter um mapa novo por partida.
Implementando o random walk
O caminhante começa no centro, dá um número fixo de passos e marca cada célula por onde passa. Só isso já gera uma caverna:
const PASSOS: int = 1200
func gerar_random_walk(largura: int, altura: int) -> void:
var posicao: Vector2i = Vector2i(largura / 2, altura / 2)
var direcoes: Array[Vector2i] = [
Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT
]
for passo: int in PASSOS:
celulas_chao[posicao] = true
var direcao: Vector2i = direcoes[rng.randi_range(0, direcoes.size() - 1)]
var proxima: Vector2i = posicao + direcao
var dentro_do_mapa: bool = (
proxima.x > 0 and proxima.x < largura - 1
and proxima.y > 0 and proxima.y < altura - 1
)
if dentro_do_mapa:
posicao = proxima
Detalhes que importam:
- O
Dictionaryresolve de graça o problema de células repetidas: escrevertrueduas vezes na mesma chave não duplica nada. E consultar se uma célula é chão é imediato, o que vai ser útil na hora de posicionar paredes e inimigos. - A checagem de limites deixa uma borda de 1 célula em volta do mapa, garantindo espaço pra pintar parede depois.
- Quando o caminhante tenta sair do mapa, ele simplesmente fica parado naquele passo. Na prática isso adensa as bordas de leve e não incomoda.
Rode a cena algumas vezes trocando a string da seed e observe: com 1200 passos num grid de 60x40 sai uma caverna compacta. Menos passos, caverna menor e mais apertada. Uma variação comum é usar vários caminhantes curtos partindo do centro em vez de um longo, o que espalha mais o mapa.
Salas e corredores: dungeon com estrutura
Agora a segunda abordagem. Primeiro, gerar as salas: sortear retângulos com Rect2i e rejeitar os que encostam em outra sala já aceita:
func gerar_salas(quantidade: int, largura: int, altura: int) -> Array[Rect2i]:
var salas: Array[Rect2i] = []
var tentativas: int = 0
while salas.size() < quantidade and tentativas < 300:
tentativas += 1
var w: int = rng.randi_range(5, 11)
var h: int = rng.randi_range(4, 8)
var x: int = rng.randi_range(1, largura - w - 1)
var y: int = rng.randi_range(1, altura - h - 1)
var nova: Rect2i = Rect2i(x, y, w, h)
var sobrepoe: bool = false
for sala: Rect2i in salas:
if nova.grow(1).intersects(sala):
sobrepoe = true
break
if not sobrepoe:
salas.append(nova)
return salas
Dois pontos aqui. O grow(1) expande o retângulo candidato em 1 célula pra todos os lados antes do teste de interseção, então as salas aceitas ficam com pelo menos 1 célula de folga entre si (sem isso, duas salas coladas viram um bloco só). E o limite de 300 tentativas evita loop infinito quando você pede mais salas do que cabe no mapa: o gerador devolve quantas conseguiu e a vida segue.
Com as salas na mão, escavamos o interior de cada uma e conectamos cada sala à anterior com um corredor em L (um trecho horizontal, depois um vertical):
func escavar_dungeon(salas: Array[Rect2i]) -> void:
for sala: Rect2i in salas:
for x: int in range(sala.position.x, sala.end.x):
for y: int in range(sala.position.y, sala.end.y):
celulas_chao[Vector2i(x, y)] = true
for i: int in range(1, salas.size()):
escavar_corredor(salas[i - 1].get_center(), salas[i].get_center())
func escavar_corredor(origem: Vector2i, destino: Vector2i) -> void:
var atual: Vector2i = origem
var passo_x: int = signi(destino.x - origem.x)
while atual.x != destino.x:
celulas_chao[atual] = true
atual.x += passo_x
var passo_y: int = signi(destino.y - origem.y)
while atual.y != destino.y:
celulas_chao[atual] = true
atual.y += passo_y
celulas_chao[destino] = true
Conectar cada sala à anterior tem uma consequência importante: conectividade por construção. Toda sala entra na rede no momento em que é criada, então não existe sala isolada e você não precisa de verificação extra de alcançabilidade. Pra usar essa abordagem, troque o corpo de gerar_dungeon por uma chamada a gerar_salas seguida de escavar_dungeon.
O corredor em L é o mais simples que funciona. Se mais tarde você quiser corredores que desviam de obstáculos ou inimigos que perseguem o jogador pelo mapa gerado, o caminho natural é pathfinding com AStar em grid, alimentando o AStarGrid2D com as mesmas células do seu Dictionary.
Populando a dungeon: jogador, inimigos e saída
Mapa vazio não é dungeon. As três regras mínimas: o jogador nasce na primeira sala, a saída fica na última (que é naturalmente distante na ordem de criação) e inimigo nenhum nasce grudado no spawn.
const DISTANCIA_SEGURA: float = 100.0 # em células ao quadrado
func popular_dungeon(salas: Array[Rect2i]) -> void:
var spawn_jogador: Vector2i = salas[0].get_center()
posicionar_jogador(spawn_jogador)
for i: int in range(1, salas.size()):
var sala: Rect2i = salas[i]
var quantos: int = rng.randi_range(1, 3)
for j: int in quantos:
var celula: Vector2i = Vector2i(
rng.randi_range(sala.position.x, sala.end.x - 1),
rng.randi_range(sala.position.y, sala.end.y - 1)
)
var delta: Vector2i = celula - spawn_jogador
if float(delta.length_squared()) > DISTANCIA_SEGURA:
spawn_inimigo(celula)
colocar_saida(salas[salas.size() - 1].get_center())
As funções posicionar_jogador, spawn_inimigo e colocar_saida são suas: em geral é instanciar uma cena e converter a célula pra posição de mundo com chao.map_to_local(celula). O que importa é a estrutura: sortear a célula dentro do Rect2i da sala, medir a distância até o spawn com length_squared() (mais barato que raiz quadrada) e descartar posições perigosas perto do jogador.
No random walk, que não tem salas, a tática muda um pouco: guarde as células do Dictionary num Array, use o primeiro ponto do caminhante como spawn e sorteie células do Array pros inimigos, aplicando o mesmo filtro de distância. Pra saída, uma opção sólida é varrer as células e escolher a mais distante do spawn. Como todas as células do random walk foram escavadas por um único caminhante contínuo, tudo é alcançável por definição.
Ajustes que mudam o feel do mapa
Com o gerador rodando, o trabalho vira design. Poucos números mudam completamente a sensação:
- Densidade (random walk): a razão entre passos e área do grid. 1200 passos em 60x40 dá uma caverna fechada e claustrofóbica; 3000 abre clareiras. Exponha isso como variável de dificuldade por andar.
- Tamanho mínimo de sala: subir o mínimo de 5x4 pra 7x6 muda o jogo de "labirinto de cubículos" pra "arenas de combate". Salas grandes pedem mais inimigos por sala; ajuste os dois juntos.
- Quantidade de salas vs. tamanho do mapa: pedir 12 salas num mapa 60x40 deixa tudo espremido e os corredores curtos. Mapa maior com as mesmas 12 salas gera corredores longos e sensação de exploração.
- Seed diária: derive a seed da data e todo jogador do mundo joga o mesmo mapa naquele dia, perfeito pra ranking e pra comunidade comparar runs.
func aplicar_seed_diaria() -> void:
var data: Dictionary = Time.get_date_dict_from_system()
var chave: String = "%04d-%02d-%02d" % [data.year, data.month, data.day]
rng.seed = hash(chave)
Teste cada mudança com a mesma seed fixa antes de julgar: só assim você sabe se a diferença veio do parâmetro ou do sorteio.
Próximos passos
Você saiu do mapa em branco pra um gerador completo: random walk pra cavernas, salas com corredores pra dungeons clássicas, tudo determinístico via seed e pintado num TileMapLayer com a API atual da Godot 4. As evoluções naturais a partir daqui são pintar paredes ao redor do chão (varra os vizinhos de cada célula do Dictionary), usar terrains do TileSet pra autotile e classificar salas por função (tesouro, chefe, loja). E se quiser ver até onde essa ideia de conteúdo por regras vai na indústria, vale conhecer geração procedural com IA em jogos.
Geração procedural é o tipo de sistema que recompensa iteração: implemente o básico deste post, jogue dez runs, anote o que incomodou e mexa num parâmetro por vez. É assim que a dungeon genérica vira a dungeon do seu jogo.
Perguntas frequentes
Como gerar uma dungeon aleatória na Godot 4?
Use um RandomNumberGenerator com seed pra decidir quais células do grid são chão, guarde essas células num Dictionary e depois pinte um TileMapLayer com set_cell. As duas técnicas mais comuns pra começar são o random walk e o algoritmo de salas com corredores.
Qual a diferença entre random walk e salas com corredores?
O random walk escava o mapa com um caminhante aleatório e gera cavernas orgânicas, sem estrutura definida. Salas com corredores gera retângulos que não se sobrepõem e liga os centros com corredores em L, criando dungeons estruturadas no estilo clássico de roguelike.
Como usar a mesma seed pra gerar o mesmo mapa na Godot?
Crie um RandomNumberGenerator e defina rng.seed com um valor fixo antes de gerar o mapa. Com a mesma seed e o mesmo código, a sequência de números é idêntica, então o mapa sai igual. Isso é essencial pra debug e pra modos de desafio diário.
Devo usar TileMap ou TileMapLayer na Godot 4?
Use TileMapLayer. Desde a Godot 4.3 o nó TileMap foi marcado como obsoleto e cada camada virou um nó TileMapLayer separado. A API é parecida: você continua chamando set_cell com coordenada, source_id e atlas_coords.
Como garantir que a saída da dungeon é alcançável?
No algoritmo de salas com corredores, conecte cada sala nova à anterior no momento em que ela é criada. Assim toda sala pertence à mesma rede de corredores por construção, e colocar a saída no centro de qualquer sala garante que ela é alcançável a partir do spawn.


