Seleção de Fases no Godot: Level Select com Desbloqueio

Aprenda a fazer seleção de fases no Godot 4: tela de level select com GridContainer, botões gerados por código, desbloqueio progressivo e save com ConfigFile.
Seu jogo já tem duas ou três fases funcionando, e agora o jogador precisa de um jeito de escolher onde jogar. Montar a seleção de fases no Godot é um dos exercícios de UI mais rentáveis para quem está começando, porque junta três habilidades que você vai reutilizar em todo projeto: gerar botões por código, guardar progresso em um autoload e salvar em disco com ConfigFile. E o resultado é aquela tela clássica de jogo de plataforma ou puzzle: uma grade de números, as primeiras fases abertas, as seguintes com cadeado.
Neste tutorial montamos tudo isso no Godot 4, com GDScript tipado: a cena do level select com GridContainer, o autoload GameProgress que sabe o que está desbloqueado, o salvamento em user://progress.cfg, a volta automática para a tela ao vencer uma fase, e dois extras rápidos, estrelas por fase e mapa múndi. Se o seu jogo ainda nem tem tela inicial, vale antes ver como criar o menu principal no Godot, porque o level select costuma ser a segunda tela da mesma estrutura, acessada pelo botão Jogar.
A estrutura: uma cena de seleção, uma cena por fase e um autoload
A arquitetura tem três peças, cada uma com uma responsabilidade só:
- level_select.tscn: uma cena
Controlcom umGridContainerdentro. É só a vitrine, ela não sabe nada sobre progresso, só pergunta. - Uma cena por fase:
level_1.tscn,level_2.tscn,level_3.tscne assim por diante, todas na mesma pasta e com o mesmo padrão de nome. - game_progress.gd: um script registrado como autoload, que guarda quantas fases estão desbloqueadas e cuida de salvar e carregar.
Na prática, o projeto fica assim:
res://
ui/level_select.tscn
fases/level_1.tscn
fases/level_2.tscn
fases/level_3.tscn
autoload/game_progress.gd
Por que o progresso vive num autoload e não na própria tela? Porque quando o jogador entra numa fase, o change_scene_to_file() destrói a cena do level select inteira. Qualquer variável que estivesse nela morre junto. O autoload é adicionado pelo Godot como filho direto da raiz quando o jogo abre e sobrevive a toda troca de cena, além de ficar acessível pelo nome em qualquer script. É exatamente o lugar para dados que precisam atravessar cenas.
O autoload GameProgress: quem lembra do que você já venceu
Crie o arquivo autoload/game_progress.gd. A ideia central cabe numa variável: fases_desbloqueadas guarda o número da fase mais alta que o jogador pode jogar. Começa em 1, porque a primeira fase nasce aberta.
extends Node
const CAMINHO_SAVE: String = "user://progress.cfg"
var fases_desbloqueadas: int = 1
func _ready() -> void:
carregar()
func fase_esta_desbloqueada(numero: int) -> bool:
return numero <= fases_desbloqueadas
func completar_fase(numero: int) -> void:
if numero >= fases_desbloqueadas:
fases_desbloqueadas = numero + 1
salvar()
func salvar() -> void:
var config: ConfigFile = ConfigFile.new()
config.set_value("progresso", "fases_desbloqueadas", fases_desbloqueadas)
config.save(CAMINHO_SAVE)
func carregar() -> void:
var config: ConfigFile = ConfigFile.new()
var erro: Error = config.load(CAMINHO_SAVE)
if erro != OK:
return
fases_desbloqueadas = config.get_value("progresso", "fases_desbloqueadas", 1)
Alguns detalhes que valem explicação. O if numero >= fases_desbloqueadas evita que rejogar a fase 1 destrave a fase errada: só avança o contador quando o jogador vence a fase da fronteira. O salvar() é chamado dentro de completar_fase(), sempre, sem depender de ninguém lembrar de salvar depois. E no carregar(), se o config.load() devolver erro, provavelmente é a primeira vez que o jogo roda e o arquivo não existe ainda, então a função simplesmente mantém os valores padrão.
O prefixo user:// aponta para a pasta de dados do jogador, que o Godot resolve sozinho em Windows, Linux, macOS, Android e web. Nunca salve em res://, que é somente leitura no jogo exportado. Esse esquema com ConfigFile é a versão mínima de um sistema de save e load no Godot: se amanhã o jogo precisar guardar moedas, opções de áudio ou posição do jogador, é o mesmo padrão, só crescem as chaves.
Para registrar o autoload, vá em Projeto, Configurações do Projeto, aba Globals (Autoload nas versões mais antigas do 4), selecione game_progress.gd, dê o nome GameProgress e clique em Adicionar. A partir daí, qualquer script do jogo enxerga GameProgress.fases_desbloqueadas.
A tela de level select: botões gerados por código
Crie a cena level_select.tscn com um Control como raiz e um GridContainer como filho. No inspetor do GridContainer, defina columns como 4 (ou quantas colunas quiser) e centralize com o preset de âncora que preferir. Não crie botão nenhum no editor: eles vão nascer por código, a partir de um array com os caminhos das fases.
extends Control
var fases: Array[String] = [
"res://fases/level_1.tscn",
"res://fases/level_2.tscn",
"res://fases/level_3.tscn",
"res://fases/level_4.tscn",
]
@onready var grid: GridContainer = $GridContainer
func _ready() -> void:
for i: int in fases.size():
var numero: int = i + 1
var botao: Button = Button.new()
botao.text = str(numero)
botao.custom_minimum_size = Vector2(96, 96)
botao.disabled = not GameProgress.fase_esta_desbloqueada(numero)
botao.pressed.connect(_on_fase_pressionada.bind(i))
grid.add_child(botao)
_focar_primeiro_botao()
func _on_fase_pressionada(indice: int) -> void:
get_tree().change_scene_to_file(fases[indice])
func _focar_primeiro_botao() -> void:
for botao: Button in grid.get_children():
if not botao.disabled:
botao.grab_focus()
return
O truque que sustenta tudo é o bind(i) na conexão do sinal. O sinal pressed do Button não envia argumento nenhum, então todos os botões chamariam a mesma função sem dizer quem são. O bind(i) gruda o índice na Callable: quando o botão da fase 3 for pressionado, _on_fase_pressionada recebe 2, e a função pega o caminho certo direto do array. Nada de criar uma função por fase nem de comparar textos de botão.
A vantagem de gerar por código em vez de arrastar botões no editor é manutenção: adicionar a fase 5 no jogo vira uma linha nova no array fases. Grade, desbloqueio e clique já funcionam sozinhos. Se preferir, dá para montar o caminho em vez de listar, com "res://fases/level_%d.tscn" % numero, mas o array explícito tem um bônus: aceita fases fora do padrão de nome, como uma fase bônus ou um chefe.
Para o visual de fase travada, o disabled = true já deixa o botão acinzentado e sem resposta ao clique. Se quiser o clássico cadeado, troque o texto por um ícone nas fases travadas, ainda dentro do loop:
if botao.disabled:
botao.text = ""
botao.icon = preload("res://ui/cadeado.png")
botao.expand_icon = true
E um detalhe de quem joga no controle: por padrão, botões desabilitados ainda podem receber foco na navegação. Se quiser que o cursor pule direto pelas fases travadas, defina botao.focus_mode = Control.FOCUS_NONE quando o botão estiver desabilitado.
Entrando na fase e voltando ao vencer
A ida você já viu: get_tree().change_scene_to_file(fases[indice]) descarrega o level select e carrega a fase. O corte seco funciona, mas se quiser dar acabamento, uma transição de cena com fade no Godot esconde a troca atrás de meio segundo de tela preta e deixa o jogo com cara de produto.
Falta a volta. Cada fase precisa saber o próprio número para avisar o GameProgress, e o jeito limpo de fazer isso é um @export: o mesmo script serve para todas as fases, e você só ajusta o número no inspetor de cada cena.
extends Node2D
@export var numero_da_fase: int = 1
func _on_bandeira_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
vencer()
func vencer() -> void:
GameProgress.completar_fase(numero_da_fase)
get_tree().change_scene_to_file("res://ui/level_select.tscn")
Aqui a bandeira é uma Area2D no fim da fase com o sinal body_entered conectado, mas o gatilho pode ser o que fizer sentido no seu jogo: derrotar o chefe, resolver o puzzle, coletar tudo. O que importa é a ordem: primeiro completar_fase(), que atualiza o contador e salva em disco, depois a troca de cena. Como os botões do level select são gerados no _ready(), a tela reabre já com a próxima fase clicável. Você não precisa atualizar botão nenhum manualmente: a tela renasce lendo o estado atual do autoload.
Extras rápidos: estrelas por fase e mapa múndi
Dois upgrades que aparecem logo depois do básico funcionar.
Estrelas por fase. Em vez de só desbloqueado ou travado, muitos jogos de puzzle dão de 1 a 3 estrelas por desempenho. Um dicionário no GameProgress resolve, com o número da fase como chave:
var estrelas: Dictionary = {}
func registrar_estrelas(numero: int, quantidade: int) -> void:
var melhor: int = int(estrelas.get(numero, 0))
if quantidade > melhor:
estrelas[numero] = quantidade
salvar()
func estrelas_da_fase(numero: int) -> int:
return int(estrelas.get(numero, 0))
No salvar(), acrescente config.set_value("progresso", "estrelas", estrelas), e no carregar(), estrelas = config.get_value("progresso", "estrelas", {}). O ConfigFile serializa dicionários sem drama. Na hora de gerar os botões, mostre o resultado no próprio texto: botao.text = str(numero) + "\n" + "★".repeat(GameProgress.estrelas_da_fase(numero)). A comparação com melhor garante que rejogar mal uma fase não apaga um recorde de 3 estrelas.
Mapa múndi. A grade é ótima para puzzle, mas jogo de plataforma pede aquele mapa com pontinhos ligados por trilha. A mudança é menor do que parece: troque o GridContainer por um TextureRect com a arte do mapa ocupando a tela, e posicione os botões manualmente em vez de deixar o container decidir. Um array de posições acompanha o array de fases:
var posicoes: Array[Vector2] = [
Vector2(120, 340),
Vector2(260, 280),
Vector2(410, 320),
]
No mesmo loop do _ready(), adicione o botão como filho do TextureRect e defina botao.position = posicoes[i]. Todo o resto, desbloqueio, bind, troca de cena, continua idêntico, porque a lógica nunca dependeu do layout.
Erros comuns que travam o level select
Quatro tropeços que aparecem com frequência nesse sistema, e como evitar cada um.
Desbloquear sem salvar. O sintoma: o jogador vence a fase 2, a fase 3 abre, ele fecha o jogo, e no dia seguinte está tudo travado de novo. A causa é atualizar fases_desbloqueadas num canto do código e deixar o salvar() para depois. A vacina é a que usamos aqui: o salvar() mora dentro de completar_fase(), então não existe caminho que desbloqueie sem persistir.
Caminho de cena errado. Se change_scene_to_file() recebe um caminho que não existe, a troca falha e um erro aparece no console. O caso traiçoeiro é maiúscula e minúscula: no editor, em Windows, res://Fases/level_1.tscn pode até funcionar com a pasta chamada fases, mas no jogo exportado o caminho é sensível a caixa e quebra. Padronize tudo em minúsculas e, se quiser dormir tranquilo, valide na geração dos botões com ResourceLoader.exists(fases[i]), desabilitando o botão de qualquer fase com caminho quebrado.
Nenhum botão com foco. No mouse tudo funciona, aí alguém pluga um controle e o direcional não faz nada. A navegação por foco do Godot precisa de um ponto de partida: sem um controle focado, não há de onde navegar. É o papel do _focar_primeiro_botao() no fim do _ready(). Se quiser um toque a mais, foque o último botão desbloqueado em vez do primeiro, que é onde o jogador provavelmente quer ir.
Não conseguir testar o desbloqueio. Para testar o fluxo do zero, apague o user://progress.cfg. O atalho é o menu Projeto, Abrir Pasta de Dados do Usuário, que abre direto a pasta certa da sua plataforma. Em projeto grande, vale criar uma função resetar() no GameProgress que zera as variáveis e salva, chamada por um botão escondido de debug.
Com isso você tem o ciclo completo: o level select lê o autoload e gera os botões, o clique leva à fase, a vitória atualiza e salva o progresso, e a volta reabre a tela com a próxima fase liberada. É um sistema pequeno, mas com a forma dos sistemas grandes: estado centralizado num autoload, UI gerada a partir de dados e persistência automática. Os mesmos três músculos que você vai usar em inventário, loja e árvore de habilidades.
Perguntas frequentes
Como fazer uma tela de seleção de fases no Godot 4?
Crie uma cena Control com um GridContainer e gere um Button por fase via código, a partir de um array com os caminhos das cenas. Um autoload GameProgress guarda quantas fases estão desbloqueadas, e cada botão chama get_tree().change_scene_to_file() com o caminho da fase.
Como desbloquear fases progressivamente no Godot?
Guarde um número no autoload, como fases_desbloqueadas, e marque cada botão com disabled quando o número da fase for maior que ele. Ao vencer uma fase, chame uma função completar_fase() que incrementa o contador e salva em disco na hora.
Onde salvar o progresso das fases no Godot?
Use ConfigFile gravando em user://progress.cfg. O prefixo user:// aponta para a pasta de dados do jogador em qualquer plataforma, e o ConfigFile lê e escreve valores por seção e chave com set_value() e get_value(), incluindo dicionários.
Como voltar da fase para o level select ao vencer?
No fim da fase, chame GameProgress.completar_fase(numero_da_fase) e depois get_tree().change_scene_to_file() apontando para a cena do level select. Como os botões são gerados no _ready(), a tela já reabre com a nova fase desbloqueada.
Como fazer o level select funcionar com controle ou joystick?
Chame grab_focus() em um botão desbloqueado no _ready() da tela. Sem um controle focado, o direcional do joystick não tem por onde começar a navegar. Também vale definir focus_mode como FOCUS_NONE nos botões travados para o cursor pular direto por eles.


