Godot com C#: nós, cenas e sinais

Godot com C#: nós, cenas e sinais

A Biblioteca em Apuros migra para o Godot com C#: tudo é um nó, nós formam cenas reutilizáveis, parâmetros vão para o Inspetor com [Export], livros avisam por sinais em vez de conhecer a partida, grupos falam com muitos nós, autoloads guardam o estado global e cada tela vira uma cena.
Linguagem C#

• • 20 min de leitura

O protótipo em MonoGame tem cada mecanismo escrito à mão. Neste artigo levamos a "Biblioteca em Apuros" para o Godot, um motor completo e gratuito, e vemos como ele organiza as mesmas ideias: tudo é um nó, nós se agrupam em cenas reutilizáveis, e as partes se comunicam por sinais. O jogo final e o capstone vão ser construídos sobre esta base.

Instalando e criando o projeto

O Godot é distribuído em duas versões, e para C# é preciso a versão .NET (antes chamada "Mono"), com o SDK do .NET instalado na máquina. O editor vem num zip de cerca de 110 MB na versão .NET (Godot 4.7), sem instalador, e ele mesmo foi feito com o Godot.

Ao criar o projeto "Biblioteca em Apuros" e anexar o primeiro script C# a um nó, o editor gera o .csproj e o .sln, que abrem normalmente no Visual Studio, no Rider ou no VS Code. O .csproj usa o SDK Godot.NET.Sdk, que traz as bibliotecas do motor (GodotSharp) e os source generators que ligam as classes C# ao motor. O editor compila o projeto a cada execução, e o depurador do IDE pode se anexar ao jogo rodando.

Uma limitação importante vale para a escolha de plataforma: até o Godot 4.7 (2026), a exportação para web de projetos Godot 4 em C# ainda não é suportada, embora a de projetos GDScript seja. Desktop é suportado desde o 4.0, e Android e iOS desde o 4.2, ainda como experimentais. Confira a documentação da versão que você usar, porque o trabalho nessa frente está em andamento.

Tudo é um nó

No MonoGame, o jogo era uma classe Game com dados e métodos. No Godot, o jogo é uma árvore de nós. Cada nó tem um tipo, que define o que ele sabe fazer, e pode ter filhos:

Tipo de nó O que faz
Node2D tem posição, rotação e escala em 2D; base de quase tudo
Sprite2D desenha uma textura
AnimatedSprite2D desenha uma sequência de quadros
CharacterBody2D corpo controlado por código que colide e desliza
StaticBody2D corpo imóvel: chão, paredes
Area2D detecta sobreposição sem colidir fisicamente
CollisionShape2D a forma de colisão de um corpo ou área
Timer conta tempo e avisa quando termina
CanvasLayer camada desenhada por cima, fixa na tela: interface
Label texto
AudioStreamPlayer toca um som

Um personagem não é uma classe gigante. É um pequeno galho da árvore:

Bibliotecaria (CharacterBody2D)   ← script Bibliotecaria.cs
├── Sprite (AnimatedSprite2D)
├── Corpo (CollisionShape2D)       ← retângulo do corpo inteiro
└── Maos (Area2D)                  ← área que apanha livros
    └── Forma (CollisionShape2D)   ← faixa estreita na altura das mãos

A hitbox das mãos, que no MonoGame era um Rectangle calculado em código, aqui é um nó Area2D com uma forma desenhada no editor, visível e ajustável com o mouse. A decisão de design de "Um game loop do zero com MonoGame" (só as mãos apanham livros) continua, mas deixa de ser um número mágico no código.

Os nós se comunicam com os pais e filhos pela árvore. A árvore inteira, com o nó raiz da janela no topo, é a SceneTree, que roda o loop: a cada quadro ela percorre os nós chamando _Process, e a cada passo físico chama _PhysicsProcess, na ordem da árvore.

Cenas: árvores reutilizáveis

Um galho de nós pode ser salvo como uma cena, um arquivo .tscn. A cena pode ser instanciada quantas vezes for preciso, dentro de outras cenas ou por código. É o equivalente do prefab da Unity, e também de uma classe em relação aos seus objetos.

A "Biblioteca em Apuros" fica com estas cenas:

cenas/
├── bibliotecaria.tscn   ← a árvore acima
├── livro.tscn           ← Area2D + Sprite2D + CollisionShape2D
├── traca.tscn           ← Area2D + AnimatedSprite2D + CollisionShape2D
├── hud.tscn             ← CanvasLayer + Labels + ícones de vida
└── principal.tscn       ← a partida: junta tudo
Principal (Node2D)                      ← Principal.cs
├── Fundo (Sprite2D)
├── Chao (StaticBody2D)
│   └── Forma (CollisionShape2D)
├── LimiteInferior (Area2D)             ← detecta livros que caíram
│   └── Forma (CollisionShape2D)
├── Bibliotecaria (instância de bibliotecaria.tscn)
├── Livros (Node2D)                     ← pai dos livros instanciados
├── Chuva (Timer)
└── Hud (instância de hud.tscn)

A cena principal é montada no editor: o fundo posicionado, o chão desenhado como um retângulo, a bibliotecária arrastada para o lugar, o Timer com seu intervalo definido nas propriedades. Todo o código de posicionamento e carregamento do protótipo MonoGame (Content.Load, coordenadas iniciais, o retângulo do chão) sai do código e vira dados editáveis. Os arquivos .tscn são texto, legíveis e versionáveis no Git, e as diferenças aparecem normalmente num pull request.

O primeiro script

using Godot;

public partial class Bibliotecaria : CharacterBody2D
{
    [Export] public float Velocidade { get; set; } = 180f;

    private static readonly StringName MoverEsquerda = "mover_esquerda";
    private static readonly StringName MoverDireita = "mover_direita";

    private AnimatedSprite2D sprite = null!;

    public override void _Ready()
    {
        sprite = GetNode<AnimatedSprite2D>("Sprite");
    }

    public override void _PhysicsProcess(double delta)
    {
        var direcao = Input.GetAxis(MoverEsquerda, MoverDireita);
        Velocity = new Vector2(direcao * Velocidade, Velocity.Y);
        MoveAndSlide();

        sprite.Play(direcao == 0 ? "parada" : "correndo");
        if (direcao != 0) sprite.FlipH = direcao < 0;
    }
}

A classe é partial, e o nome do arquivo precisa coincidir com o nome da classe. Os source generators do Godot completam a classe com o código que registra métodos, propriedades exportadas e sinais no motor, o que antes exigia reflexão. É a mesma técnica de "Source generators: código que escreve código" e do CommunityToolkit.Mvvm.

[Export] faz a propriedade aparecer no Inspetor do editor. O designer ajusta a velocidade da bibliotecária e testa na hora, sem recompilar. O valor fica salvo na cena e sobrescreve o padrão do código. Exportar parâmetros de jogabilidade é a forma de tirar do programador o papel de gargalo do ajuste fino, e de permitir variações: uma segunda bibliotecária, mais rápida, é a mesma cena com outro valor.

O ciclo de vida de um nó tem uma ordem definida: _EnterTree quando entra na árvore, _Ready quando ele e todos os filhos entraram (e por isso é o lugar certo para buscar filhos), _Process e _PhysicsProcess enquanto está na árvore, _ExitTree ao sair. A regra da dupla _Process e _PhysicsProcess foi discutida no exercício 5 de "C# nos jogos": o que afeta a simulação vai no passo físico.

StringName estático. As ações de entrada ("mover_esquerda") são identificadas por StringName, um tipo do Godot para strings internadas e comparadas por referência. Passar uma string literal a cada chamada funciona, porque há conversão implícita, mas converte a cada quadro, com custo de interoperação e alocação. Guardar os nomes em campos estáticos faz a conversão uma única vez. É o mesmo raciocínio do texto de pontuação em cache do protótipo, na forma que o Godot pede.

Chegando aos outros nós

GetNode<T>("Sprite") busca um filho pelo caminho na árvore. Caminhos quebram quando alguém renomeia ou move um nó no editor, e o erro só aparece em tempo de execução. Há duas alternativas mais robustas:

[Export] public AnimatedSprite2D Sprite { get; set; } = null!;   // ligado no Inspetor
var pontos = GetNode<Label>("%Pontos");   // nome único na cena, independe do caminho

Exportar a referência ao nó faz o editor mostrar um campo em que se arrasta o nó certo. Se ele for renomeado, a referência continua válida. O prefixo % busca um nó marcado como "nome único" na cena, em qualquer profundidade. Para dependências que sempre existem na cena, a referência exportada é a mais segura.

Instanciando livros

public partial class Principal : Node2D
{
    [Export] public PackedScene CenaDoLivro { get; set; } = null!;
    [Export] public Node2D PaiDosLivros { get; set; } = null!;
    [Export] public Timer Chuva { get; set; } = null!;
    [Export] public Hud Hud { get; set; } = null!;
    [Export] public Area2D LimiteInferior { get; set; } = null!;

    private readonly RandomNumberGenerator sorteio = new();
    private float tempoDePartida;
    private int pontos;
    private int vidas = 3;

    public override void _Ready()
    {
        Chuva.Timeout += SoltarLivro;
        LimiteInferior.AreaEntered += AoAlgoCairNoChao;
        Hud.Atualizar(pontos, vidas);
    }

    public override void _PhysicsProcess(double delta)
    {
        tempoDePartida += (float)delta;
        var dificuldade = 1f + tempoDePartida / 30f;
        Chuva.WaitTime = Mathf.Max(0.25f, 1.2f / dificuldade);
    }

    private void SoltarLivro()
    {
        var livro = CenaDoLivro.Instantiate<Livro>();
        livro.Raro = sorteio.Randf() < 0.1f;
        livro.Velocidade = (livro.Raro ? 110f : 70f) * (1f + tempoDePartida / 30f);
        livro.Position = new Vector2(sorteio.RandfRange(8, 472), -16);
        livro.Apanhado += AoApanharLivro;
        PaiDosLivros.AddChild(livro);
    }

    private void AoApanharLivro(int valor)
    {
        pontos += valor;
        Hud.Atualizar(pontos, vidas);
    }

    private void AoAlgoCairNoChao(Area2D area)
    {
        if (area is not Livro livro) return;
        livro.QueueFree();
        PerderVida();
    }

    private void PerderVida()
    {
        vidas--;
        Hud.Atualizar(pontos, vidas);
        if (vidas <= 0) FimDeJogo();
    }
}

PackedScene é uma cena carregada, pronta para ser instanciada. É exportada, e o livro.tscn é arrastado para o campo no Inspetor. Instantiate<Livro>() cria a árvore inteira da cena e devolve o nó raiz já convertido para o tipo do script. AddChild o coloca na árvore, e a partir daí o motor o atualiza e desenha.

QueueFree remove o nó no fim do quadro atual, e não imediatamente. Remover um nó no meio da iteração da árvore, enquanto outros nós ainda podem referenciá-lo naquele quadro, causaria erros. A remoção adiada é a mesma ideia de "desativar" do pool do protótipo, com a diferença de que aqui o nó é realmente destruído. Instanciar e destruir nós tem um custo maior que ativar uma struct num arranjo. Para uma chuva de livros a cada meio segundo, é irrelevante. Para centenas de projéteis por segundo, um pool de nós reaproveitados volta a ser a resposta, e esse é um dos temas de "Arquitetura de jogo e por que não alocar dentro do loop".

Sinais: o livro avisa, a partida decide

No protótipo MonoGame, a ChuvaDeLivros devolvia um ResultadoDaChuva com os pontos e os livros perdidos, e a Partida os somava. A chuva precisava saber que existia pontuação. No Godot, cada livro é um nó independente, e a forma de ele contar o que aconteceu é emitir um sinal:

public partial class Livro : Area2D
{
    [Signal] public delegate void ApanhadoEventHandler(int valor);

    public bool Raro { get; set; }
    public float Velocidade { get; set; }

    public override void _Ready()
    {
        AreaEntered += AoEncostar;
        if (Raro) GetNode<Sprite2D>("Sprite").Texture = GD.Load<Texture2D>("res://sprites/livro_raro.png");
    }

    public override void _PhysicsProcess(double delta)
    {
        Position += new Vector2(0, Velocidade * (float)delta);
    }

    private void AoEncostar(Area2D outra)
    {
        if (outra.Name != "Maos") return;
        EmitSignal(SignalName.Apanhado, Raro ? 50 : 10);
        QueueFree();
    }
}

O sinal é declarado como um delegate com o atributo [Signal], e o nome precisa terminar em EventHandler. O source generator cria a partir dele um evento C# chamado Apanhado e uma constante SignalName.Apanhado. Por isso a Principal pode escrever livro.Apanhado += AoApanharLivro, com a sintaxe de eventos de "Delegates, lambdas e eventos", checada pelo compilador.

Os nós do próprio motor também têm sinais: Timer.Timeout, Area2D.AreaEntered, Area2D.BodyEntered, Button.Pressed, AnimatedSprite2D.AnimationFinished. É assim que um motor orientado a nós liga as peças sem que elas se conheçam.

A regra que orienta o uso dos sinais costuma ser resumida como "chame para baixo, sinalize para cima":

  • Um nó pai conhece seus filhos e pode chamar métodos deles diretamente: a Principal chama Hud.Atualizar(...).
  • Um nó filho não deve conhecer o pai: o Livro não sabe que existe uma Principal nem uma pontuação. Ele emite um sinal, e quem se interessar escuta.

Com isso, a cena do livro funciona em qualquer contexto. Numa tela de testes sem pontuação, ninguém escuta o Apanhado, e nada quebra. Num modo cooperativo com duas bibliotecárias, a partida escuta o mesmo sinal e decide de quem é o ponto. É o mesmo princípio que separou o EventCallback do filho e a decisão do pai em "Blazor: componentes, parâmetros, eventos e ciclo de vida", e a mesma ideia de desacoplar quem produz um fato de quem reage a ele de "Worker Services e mensageria com RabbitMQ", em escala de milissegundos.

Sinais também podem ser conectados no editor, na aba "Nó" do painel lateral, sem nenhuma linha de código de ligação. Para C#, conectar em código tem uma vantagem: um erro de digitação no nome do método é pego pelo compilador, e não só quando o sinal é emitido.

Cuidado com as assinaturas

Um sinal conectado com += a um método de um nó mantém uma referência a esse nó. O Godot desconecta automaticamente os sinais quando o nó que emite ou o que escuta é liberado com QueueFree, desde que a conexão tenha sido feita para um método de um objeto do Godot. Uma lambda que captura variáveis, conectada a um sinal, não tem esse vínculo com o ciclo de vida de um nó e pode sobreviver a ele. Quando for preciso, a conexão explícita com Connect(SignalName.Apanhado, Callable.From<int>(AoApanharLivro)) e a desconexão com Disconnect dão controle total. É a mesma lição sobre assinaturas de eventos do MiniCarrinho em "Blazor: modos de renderização, WebAssembly e estado".

Grupos: falar com muitos ao mesmo tempo

Quando a partida termina, todos os livros e traças na tela devem parar. A partida poderia percorrer os filhos de Livros e de Tracas, mas o Godot oferece grupos, etiquetas que qualquer nó pode ter:

// em Livro._Ready e Traca._Ready
AddToGroup("perigos_ativos");

// em Principal.FimDeJogo
GetTree().CallGroup("perigos_ativos", Node.MethodName.SetPhysicsProcess, false);
Chuva.Stop();
Hud.MostrarFimDeJogo(pontos);

CallGroup chama um método em todos os nós do grupo, estejam onde estiverem na árvore. Grupos também servem para consultas como "quantos livros estão caindo agora?", com GetTree().GetNodesInGroup("livros").Count.

Estado global: autoloads

O recorde, as configurações de volume e, no capstone, a sessão do jogador com a API precisam sobreviver à troca de cenas: ao sair da partida para o menu, a cena principal é descartada inteira. O Godot resolve com autoloads, cenas ou scripts carregados automaticamente na inicialização e mantidos na raiz da árvore enquanto o jogo roda:

public partial class EstadoDoJogo : Node
{
    public int Recorde { get; private set; }

    [Signal] public delegate void RecordeBatidoEventHandler(int novo);

    public void RegistrarPartida(int pontos)
    {
        if (pontos <= Recorde) return;
        Recorde = pontos;
        EmitSignal(SignalName.RecordeBatido, pontos);
    }
}

Registrado em Projeto → Configurações do Projeto → Globais com o nome EstadoDoJogo, ele fica acessível de qualquer nó por GetNode<EstadoDoJogo>("/root/EstadoDoJogo"). Autoloads são singletons, com as mesmas vantagens e os mesmos riscos dos singletons da DI de "Um repositório, três bancos". Servem bem para estado que é global de verdade (preferências, recorde, sessão). Usados como depósito de qualquer coisa que dois nós precisem compartilhar, eles criam um acoplamento difícil de desfazer.

Trocando de cena

O menu, a partida e a tela de fim de jogo são cenas diferentes:

GetTree().ChangeSceneToFile("res://cenas/principal.tscn");

ChangeSceneToFile descarta a cena atual inteira (todos os nós, sinais e timers) e carrega a nova. Os autoloads permanecem. É a máquina de estados do protótipo MonoGame (Menu, Jogando, FimDeJogo), em que cada estado virou uma cena.

C# e GDScript no mesmo projeto

O Godot permite misturar as duas linguagens: um nó com script GDScript pode chamar métodos de um nó C#, e vice-versa, pelas APIs genéricas Call e Get. Muitos projetos usam GDScript para scripts pequenos de interface e C# para sistemas maiores, que se beneficiam de tipagem forte, do ecossistema NuGet e de testes com xUnit. A Livraria usa só C#: o capstone vai compartilhar os contratos Livraria.Contratos com a API, algo que só é possível do lado C#.

Um ponto de atenção quando se vem do .NET "puro": cada chamada de C# para o motor (ler Position, chamar MoveAndSlide) atravessa uma fronteira de interoperação com o núcleo em C++. É rápido, mas não é gratuito. Em código que roda para milhares de objetos por quadro, vale guardar em variáveis locais os valores lidos do motor, e preferir as coleções .NET (List<T>, arranjos) às coleções do Godot (Godot.Collections.Array) para dados que só o C# usa, porque estas convertem valores a cada acesso.

A "Biblioteca em Apuros" agora é um projeto Godot. A bibliotecária, os livros, a interface e a partida são cenas montadas no editor, com parâmetros ajustáveis no Inspetor e scripts C# pequenos, cada um cuidando de um nó. Os livros não conhecem a pontuação e só avisam que foram apanhados, e a partida decide o que isso significa. O que ainda é rudimentar é o que acontece quando as coisas se encostam: a bibliotecária só anda para os lados, os livros atravessam tudo em linha reta e as traças ainda não existem.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um desenvolvedor faz o Livro chamar diretamente GetNode<Principal>("/root/Principal").SomarPontos(10) quando é apanhado. Funciona na partida. Dê três situações em que isso quebra e mostre como o sinal evita cada uma.

Ver resposta

✓ Resposta: (1) Testar a cena do livro isolada (F6 no editor): não existe /root/Principal, e o GetNode falha com erro. Com sinal, o livro emite e ninguém escuta, sem erro. (2) Renomear ou reorganizar a cena principal, por exemplo colocando a partida dentro de um nó Mundo: o caminho muda e todos os livros quebram. Com sinal, o livro nem sabe onde está a partida. (3) Reutilizar o livro em outro modo, como um tutorial que conta os livros apanhados sem pontuação, ou um modo com duas bibliotecárias em que o ponto vai para quem apanhou: o livro teria que conhecer cada modo. Com sinal, cada modo conecta seu próprio tratador e decide o que fazer. O acoplamento vai do livro para a partida, e o sinal o inverte: a partida conhece os livros que cria e escolhe escutá-los.

Exercício 2

Por que GetNode<AnimatedSprite2D>("Sprite") no construtor da classe Bibliotecaria falharia, enquanto em _Ready funciona? E por que _Ready do pai roda depois do _Ready dos filhos?

Ver resposta

✓ Resposta: O construtor da classe C# roda quando o objeto é criado, antes de a cena ter montado a árvore e antes de o nó entrar nela. Nesse momento, os filhos ainda não foram adicionados, e não há Sprite para encontrar. _Ready é chamado quando o nó entrou na árvore e todos os seus filhos também já estão prontos. Por isso o Godot chama _Ready de baixo para cima: primeiro os filhos, depois o pai. Assim, no _Ready do pai, os filhos já executaram suas inicializações e podem ser usados com segurança, com seus sinais conectados e suas propriedades definidas. _EnterTree, ao contrário, é chamado de cima para baixo, e nele os filhos ainda podem não estar prontos.

Exercício 3

Na Principal, o intervalo da chuva é recalculado em _PhysicsProcess a cada passo físico. Proponha uma forma de reduzir esse trabalho e discuta se vale a pena.

Ver resposta

✓ Resposta: Recalcular WaitTime só quando o Timer dispara, dentro de SoltarLivro: a cada livro solto, o intervalo até o próximo é ajustado para a dificuldade atual, com Chuva.WaitTime = ... e, se o timer não for one-shot, o novo valor vale a partir do próximo ciclo. São 60 atribuições por segundo contra uma ou duas. Vale a pena? Em desempenho, quase nada: é uma conta simples e uma chamada de interoperação por passo, irrelevante perto de desenhar a tela. Em clareza, sim: a dificuldade afeta o intervalo no único momento em que ele importa, e o código fica mais fácil de entender e testar. A lição é que a motivação para mudar código de jogo nem sempre é desempenho. Muitas vezes é fazer cada coisa acontecer no evento a que ela pertence. Para medir se algo pesa de fato, o profiler do Godot mostra o tempo por função a cada quadro.

Exercício 4

Explique a diferença entre desconectar um sinal ligado a um método de um nó e um sinal ligado a uma lambda que captura uma variável, quando o nó que escuta é liberado com QueueFree.

Ver resposta

✓ Resposta: Uma conexão para um método de um objeto do Godot (livro.Apanhado += AoApanharLivro, sendo AoApanharLivro um método de uma instância de nó) é registrada com a referência ao objeto de destino. Quando esse objeto é liberado, o Godot sabe que o destino deixou de existir e remove a conexão automaticamente, sem chamadas a um objeto morto. Uma lambda como livro.Apanhado += valor => contador += valor cria um delegate cujo alvo é um objeto de closure gerado pelo compilador, que não é um nó e não tem ciclo de vida no motor. Se a lambda capturar um nó que depois é liberado, o sinal ainda chamará a lambda, e ela tentará usar um nó já liberado, gerando erros de "instância liberada" ou comportamento estranho. A regra prática: conecte sinais a métodos de nós sempre que possível, e quando usar lambdas, desconecte-as explicitamente em _ExitTree, ou verifique IsInstanceValid(no) dentro da lambda.

Exercício 5

O recorde está no autoload EstadoDoJogo, na memória. Ao fechar o jogo, ele se perde. Esboce como persisti-lo e em que momento gravar, sem ainda usar a API da Livraria.

Ver resposta

✓ Resposta: Gravar num arquivo no diretório de dados do usuário, que o Godot expõe como user:// e mapeia para o local correto em cada sistema (por exemplo %APPDATA%\Godot\app_userdata\Biblioteca em Apuros no Windows e ~/.local/share/godot/app_userdata/... no Linux). Com as APIs do .NET: var caminho = ProjectSettings.GlobalizePath("user://recorde.json"); File.WriteAllText(caminho, JsonSerializer.Serialize(new { Recorde }));, e em _Ready do autoload ler o arquivo se ele existir, tratando arquivo ausente ou corrompido como recorde zero. Quando gravar: no momento em que o recorde é batido, em RegistrarPartida, e não no fechamento do jogo, porque o jogo pode ser encerrado à força ou travar, e a notificação de fechamento não é garantida em todas as plataformas. Para escrita segura, gravar num arquivo temporário e renomeá-lo sobre o original, de modo que uma queda no meio da escrita não deixe um arquivo pela metade. O save game completo, com várias informações e versão de formato, é assunto de "Áudio, interface e save game".

Comentários

Mais em Linguagem C#

Áudio, interface e save game: o que faz um jogo parecer pronto
Áudio, interface e save game: o que faz um jogo parecer pronto

O que faz a Biblioteca em Apuros parecer pronta: barramentos de áudio e volume…

Concorrência: Threads, Travas e Dados Compartilhados Sem Corrupção
Concorrência: Threads, Travas e Dados Compartilhados Sem Corrupção

Concorrência no C#: condição de corrida e por que contador++ perde…

C# nos jogos: Unity, Godot, MonoGame e o game loop
C# nos jogos: Unity, Godot, MonoGame e o game loop

Como o C# chegou aos jogos pela Unity, pelo XNA e MonoGame e pelo Godot, a…