Voltar para o Blog
Quest Log

Árvore de Habilidades (Skill Tree) no Godot 4: Guia Prático

Painel de progressão de personagem com nós circulares conectados por linhas brilhantes sobre fundo escuro

Árvore de habilidades no Godot 4: modele nós com Resource, cheque pré-requisitos, gaste pontos e monte a UI com botões usando GDScript tipado.

Árvore de Habilidades (Skill Tree) no Godot 4: Guia Prático

Uma árvore de habilidades é aquele painel onde o jogador gasta pontos para desbloquear melhorias e destravar novos ramos de poder. Ela aparece em RPGs, roguelikes e até em jogos de ação. Neste guia prático você vai montar uma do zero no Godot 4, separando os dados dos nós da lógica de desbloqueio e da interface. A ideia central é simples: cada nó é um pedaço de dados, cada dado sabe seu custo e seus pré-requisitos, e a UI apenas reflete esse estado.

O erro mais comum de quem começa é misturar tudo dentro dos botões da tela. Isso funciona por dez minutos e vira um pesadelo assim que a árvore cresce. Aqui no CursoGame.Dev a gente prega o contrário: dados de um lado, regras no meio, tela por último. Vamos por partes.

Modelando os dados de um nó da árvore de habilidades

O primeiro passo para uma árvore de habilidades sustentável é decidir como representar cada nó. A opção mais limpa no Godot 4 é uma classe de dados que herda de Resource. Com Resource você ganha tipagem, autocomplete e a possibilidade de criar cada nó como um arquivo .tres editável no inspector, sem tocar em código.

class_name SkillNode
extends Resource

## Identificador único do nó. Usado nos pré-requisitos.
@export var id: String = ""

## Nome mostrado ao jogador.
@export var display_name: String = ""

## Descrição curta do efeito.
@export var description: String = ""

## Quantos pontos custa desbloquear.
@export var cost: int = 1

## Ids dos nós que precisam estar desbloqueados antes deste.
@export var prerequisites: Array[String] = []

Repare que prerequisites é um Array[String] tipado. Isso não é firula: o editor passa a validar o conteúdo e o autocomplete funciona quando você percorre a lista. Cada nó guarda apenas o próprio id e os ids de quem vem antes. Um nó de raiz simplesmente tem prerequisites vazio.

Com essa classe pronta, você cria os nós como recursos. Por exemplo, um forca_1.tres com id = "forca_1", custo 1 e sem pré-requisitos, e um forca_2.tres com id = "forca_2" e prerequisites = ["forca_1"]. A árvore inteira vira uma coleção desses arquivos, que você agrupa num único recurso de definição:

class_name SkillTreeData
extends Resource

## Todos os nós que compõem esta árvore.
@export var nodes: Array[SkillNode] = []

func get_node_by_id(node_id: String) -> SkillNode:
    for node in nodes:
        if node.id == node_id:
            return node
    return null

O método get_node_by_id retorna o SkillNode correspondente ou null se o id não existir. Ter tipo de retorno explícito (-> SkillNode) deixa claro o contrato e ajuda o Godot a apontar erros cedo.

A lógica de desbloqueio: pontos e pré-requisitos

Agora vem o coração do sistema. Precisamos de um objeto que guarde o estado do jogador: quantos pontos ele tem e quais nós já desbloqueou. Vou usar um Node para isso, já que ele participa da cena e pode emitir sinais para a UI reagir.

class_name SkillTreeState
extends Node

signal points_changed(new_total: int)
signal node_unlocked(node_id: String)

@export var tree_data: SkillTreeData

var available_points: int = 0
var unlocked: Dictionary = {}

func add_points(amount: int) -> void:
    available_points += amount
    points_changed.emit(available_points)

func is_unlocked(node_id: String) -> bool:
    return unlocked.has(node_id)

Usei um Dictionary chamado unlocked para guardar os ids já destravados. Poderia ser um Array, mas o dicionário dá busca rápida por chave: unlocked.has(node_id) responde em tempo constante, o que importa quando a UI consulta muitos nós por quadro.

O passo seguinte é checar se um nó pode ser desbloqueado. São três condições: o nó existe, ainda não foi desbloqueado e todos os pré-requisitos já estão ativos. Além disso, o jogador precisa ter pontos suficientes.

func has_prerequisites(node: SkillNode) -> bool:
    for required_id in node.prerequisites:
        if not unlocked.has(required_id):
            return false
    return true

func can_unlock(node_id: String) -> bool:
    var node: SkillNode = tree_data.get_node_by_id(node_id)
    if node == null:
        return false
    if unlocked.has(node_id):
        return false
    if available_points < node.cost:
        return false
    return has_prerequisites(node)

can_unlock é uma função pura de consulta: ela não muda nada, apenas responde true ou false. Isso é ótimo porque a UI pode usar a mesma função para decidir se pinta o botão como disponível ou bloqueado, sem duplicar regra.

A ação de fato acontece em try_unlock. Ela reusa can_unlock, gasta os pontos, registra o nó e avisa quem estiver ouvindo:

func try_unlock(node_id: String) -> bool:
    if not can_unlock(node_id):
        return false
    var node: SkillNode = tree_data.get_node_by_id(node_id)
    available_points -= node.cost
    unlocked[node_id] = true
    points_changed.emit(available_points)
    node_unlocked.emit(node_id)
    return true

Note o retorno bool: quem chamou sabe se deu certo. Se o jogador clicou num nó sem pontos ou sem pré-requisito, try_unlock devolve false e o estado permanece intacto. Nenhuma exceção, nenhum efeito colateral escondido. Esse tipo de garantia é o que faz a diferença entre um sistema confiável e um bug intermitente que só aparece na build final.

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

Montando a UI com botões

Com os dados e a lógica prontos, a tela fica quase trivial. Cada nó vira um Button (ou uma cena baseada em Button) que conhece seu próprio id. Ao ser clicado, ele avisa o estado, e o estado decide o resto. Vou usar uma pequena cena de botão que guarda o id do nó.

class_name SkillButton
extends Button

signal node_pressed(node_id: String)

@export var node_id: String = ""

func _ready() -> void:
    pressed.connect(_on_pressed)

func _on_pressed() -> void:
    node_pressed.emit(node_id)

func set_state(is_owned: bool, is_available: bool) -> void:
    if is_owned:
        disabled = true
        modulate = Color(0.4, 1.0, 0.4)
    elif is_available:
        disabled = false
        modulate = Color(1.0, 1.0, 1.0)
    else:
        disabled = true
        modulate = Color(0.5, 0.5, 0.5)

O SkillButton faz duas coisas: emite node_pressed com o próprio id quando clicado, e expõe set_state para a tela colorir o botão conforme a situação. Nó já comprado fica verde e desabilitado; nó disponível fica branco e clicável; nó ainda trancado fica cinza e desabilitado. Usar modulate é uma forma barata de dar retorno visual sem trocar texturas.

Falta o controlador da tela, que conecta cada botão ao estado e escuta os sinais. Suponha que os botões estão como filhos de um Control e cada um tem seu node_id preenchido no editor.

class_name SkillTreeUI
extends Control

@export var state: SkillTreeState
@onready var buttons_root: Control = $Buttons
@onready var points_label: Label = $PointsLabel

func _ready() -> void:
    for child in buttons_root.get_children():
        if child is SkillButton:
            var button: SkillButton = child
            button.node_pressed.connect(_on_node_pressed)
    state.points_changed.connect(_on_points_changed)
    state.node_unlocked.connect(_on_node_unlocked)
    refresh()

func _on_node_pressed(node_id: String) -> void:
    state.try_unlock(node_id)

func _on_points_changed(new_total: int) -> void:
    points_label.text = "Pontos: %d" % new_total

func _on_node_unlocked(_node_id: String) -> void:
    refresh()

O refresh percorre os botões e pergunta ao estado como cada um deve aparecer. Ele reusa is_unlocked e can_unlock, ou seja, a mesma regra que valida o clique também pinta a tela. Zero duplicação.

func refresh() -> void:
    for child in buttons_root.get_children():
        if child is SkillButton:
            var button: SkillButton = child
            var owned: bool = state.is_unlocked(button.node_id)
            var available: bool = state.can_unlock(button.node_id)
            button.set_state(owned, available)
    points_label.text = "Pontos: %d" % state.available_points

O fluxo completo fica assim: o jogador clica num SkillButton, que emite node_pressed. O SkillTreeUI recebe e chama state.try_unlock. Se der certo, o estado emite node_unlocked e points_changed, a UI ouve, chama refresh e a tela se atualiza sozinha. Cada camada faz só o seu papel.

Desenhando as conexões entre os nós

Uma árvore de verdade mostra linhas ligando os nós aos seus pré-requisitos. Você não precisa de nada sofisticado: um script no Control de fundo pode desenhar as linhas em _draw usando a posição de cada botão. Como cada nó já sabe seus prerequisites, basta traçar uma linha do botão até o botão de cada requisito.

func _draw() -> void:
    for child in buttons_root.get_children():
        if not child is SkillButton:
            continue
        var button: SkillButton = child
        var node: SkillNode = state.tree_data.get_node_by_id(button.node_id)
        if node == null:
            continue
        for required_id in node.prerequisites:
            var target: SkillButton = _find_button(required_id)
            if target != null:
                draw_line(button.position, target.position, Color.GRAY, 2.0)

O método _find_button é uma busca simples que devolve o SkillButton de um id. Chame queue_redraw() sempre que a árvore mudar para as linhas acompanharem. Aqui você pode caprichar: pintar de dourado as conexões já desbloqueadas, animar a espessura, o que a estética do jogo pedir.

Como isso conversa com o resto do jogo

A árvore de habilidades quase nunca vive sozinha. Os efeitos dos nós precisam alterar atributos do personagem, e esse progresso precisa persistir entre sessões. Quando um nó é desbloqueado, o sinal node_unlocked é o gancho perfeito para aplicar o bônus: some vida máxima, aumente o dano, libere uma habilidade nova.

Se o seu RPG já tem stats de personagem, vale integrar a árvore com a lógica que também alimenta a HUD de vida e munição, assim os pontos gastos aparecem na tela na hora. E como a progressão é valiosa para o jogador, você vai querer salvar e carregar o progresso: basta serializar as chaves de unlocked e o valor de available_points. Para árvores que liberam itens ou equipamentos, conectar com um sistema de inventário para RPG fecha o ciclo entre progredir e usar o que foi conquistado.

Um detalhe de projeto: mantenha a SkillTreeData como a única fonte de verdade sobre a estrutura da árvore, e o SkillTreeState como a única fonte de verdade sobre o que o jogador desbloqueou. Nunca guarde o estado dentro dos botões. Se um dia você trocar toda a UI, a lógica continua de pé, e é isso que permite iterar rápido no visual sem quebrar as regras.

Próximo passo

Você já tem as três camadas: SkillNode e SkillTreeData para os dados, SkillTreeState para as regras de pontos e pré-requisitos, e SkillButton com SkillTreeUI para a tela. O próximo passo é criar de fato os arquivos .tres dos seus nós e montar a cena, ligando cada botão ao seu node_id. Comece pequeno, com três nós em linha, confirme que o desbloqueio respeita a ordem, e só então expanda para ramos paralelos.

Depois disso, experimente adicionar níveis por nó (comprar o mesmo nó várias vezes), custos crescentes ou pontos ganhos ao subir de nível. A base tipada que você montou aqui aguenta essas evoluções sem reescrita. Se quiser continuar aprofundando em sistemas de jogo com Godot, o CursoGame.Dev tem outros guias práticos ligados nos links acima para você seguir construindo.

Perguntas frequentes

Preciso de um plugin para fazer uma skill tree no Godot 4?

Não. Dá para montar tudo com nós nativos: Resource para os dados, Control e Button para a UI e GDScript tipado para a lógica. Plugins ajudam a desenhar visualmente, mas não são obrigatórios.

Devo usar Resource ou dicionário para representar os nós?

Resource com class_name é mais robusto: dá autocomplete, tipagem e permite editar os nós direto no inspector como arquivos .tres. Dicionário serve para protótipos rápidos, mas perde a validação de tipos.

Como guardo os pré-requisitos de cada nó?

Guarde uma lista com os identificadores (ids) dos nós que precisam estar desbloqueados antes. Na hora de desbloquear, você percorre essa lista e confirma que todos já estão no conjunto de nós ativos.

Como salvo a árvore de habilidades desbloqueada?

Serialize o conjunto de ids desbloqueados e a quantidade de pontos restantes. Um Array de String ou um dicionário simples já resolve, e você grava isso junto do resto do save do jogador.

Como desbloqueio um nó ao clicar no botão?

Conecte o sinal pressed do Button a um método que recebe o id do nó. Esse método chama a lógica de desbloqueio, que valida pontos e pré-requisitos antes de confirmar.

Como impeço o jogador de gastar pontos que não tem?

Antes de desbloquear, compare o custo do nó com os pontos disponíveis. Se o custo for maior, a função retorna false e nada acontece, mantendo o estado consistente.