Arquitetura de jogo e por que não alocar dentro do loop

Arquitetura de jogo e por que não alocar dentro do loop

A Biblioteca em Apuros ganha arquitetura: máquinas de estados para a personagem e as telas, componentes de vida e dano no lugar de herança, regras da partida num projeto testável sem o Godot, um barramento de eventos com moderação, pools de nós, partículas orientadas a dados e a medição do coletor.
Linguagem C#

• • 25 min de leitura

A "Biblioteca em Apuros" já é jogável, mas cresceu do jeito que protótipos crescem: a cena principal decide tudo, as regras estão espalhadas por scripts de nós, e livros e traças são criados e destruídos o tempo todo. Neste artigo damos estrutura ao jogo com máquinas de estados, componentes e uma camada de regras testável sem o motor. Também medimos o custo das alocações dentro do loop e aplicamos o que os artigos sobre o coletor de lixo e sobre Span ensinaram na Fase 4.

O problema do "script que faz tudo"

Depois de três artigos, a classe Principal sabe soltar livros, contar pontos, tirar vidas, atualizar a interface, parar a chuva no fim do jogo e trocar de cena. A Bibliotecaria sabe andar, pular, piscar quando é atingida, e decide a própria animação com uma coleção crescente de if. Cada funcionalidade nova (o multiplicador por sequência, o marcador de página que dá vida, uma pausa com menu) mexe nesses dois arquivos.

É o mesmo problema que a Livraria enfrentou na Fase 2, em "Do Arquivo Único à Solução", quando o Program.cs do console fazia tudo, e a solução tem a mesma forma: separar responsabilidades e separar as regras do mecanismo. Em jogos, isso se faz com quatro ferramentas: máquinas de estados, componentes, uma camada de regras pura e, quando o número de entidades cresce muito, orientação a dados.

Máquinas de estados

O personagem

A bibliotecária tem comportamentos diferentes em situações diferentes. No chão, anda e pode pular. No ar, controla menos a direção e não pode pular de novo. Atordoada depois de um golpe, perde o controle por um instante. Com if, isso vira:

if (!atordoada && (IsOnFloor() || coiote > 0) && buffer > 0 && !pulando) ...

e cada estado novo multiplica as combinações. Uma máquina de estados torna explícito que a personagem está em exatamente um estado por vez, e que cada estado tem regras próprias:

public partial class Bibliotecaria : CharacterBody2D
{
    private enum Estado { NoChao, NoAr, Atordoada }

    private Estado estado = Estado.NoChao;
    private float tempoNoEstado;

    public override void _PhysicsProcess(double delta)
    {
        var dt = (float)delta;
        tempoNoEstado += dt;

        var proximo = estado switch
        {
            Estado.NoChao    => AtualizarNoChao(dt),
            Estado.NoAr      => AtualizarNoAr(dt),
            Estado.Atordoada => AtualizarAtordoada(dt),
            _ => estado
        };

        MoveAndSlide();
        MudarPara(proximo);
    }

    private Estado AtualizarNoChao(float dt)
    {
        MoverHorizontal(dt, controle: 1f);
        if (buffer > 0) { Pular(); return Estado.NoAr; }
        return IsOnFloor() ? Estado.NoChao : Estado.NoAr;
    }

    private Estado AtualizarNoAr(float dt)
    {
        MoverHorizontal(dt, controle: 0.7f);          // menos controle no ar
        AplicarGravidade(dt);
        if (tempoNoEstado < TempoCoiote && buffer > 0 && !pulouNesteVoo) { Pular(); }
        return IsOnFloor() ? Estado.NoChao : Estado.NoAr;
    }

    private Estado AtualizarAtordoada(float dt)
    {
        AplicarGravidade(dt);                         // sem controle horizontal
        return tempoNoEstado >= 0.35f
            ? (IsOnFloor() ? Estado.NoChao : Estado.NoAr)
            : Estado.Atordoada;
    }

    private void MudarPara(Estado novo)
    {
        if (novo == estado) return;
        AoSair(estado);
        estado = novo;
        tempoNoEstado = 0;
        AoEntrar(novo);
    }

    private void AoEntrar(Estado e)
    {
        switch (e)
        {
            case Estado.NoChao:    sprite.Play("parada"); pulouNesteVoo = false; break;
            case Estado.NoAr:      sprite.Play("pulando"); break;
            case Estado.Atordoada: sprite.Play("atingida"); break;
        }
    }

    private void AoSair(Estado e) { /* sons, partículas de pouso... */ }
}

Cada método de estado responde a uma pergunta só: estando aqui, o que faço neste passo e para onde vou? As transições ficam num único lugar, e AoEntrar e AoSair concentram os efeitos que acontecem uma vez na mudança: tocar a animação, emitir partículas de poeira no pouso, tocar o som do golpe. O tempo coiote do artigo anterior virou algo natural: é só "há quanto tempo estou no estado NoAr".

Para três ou quatro estados, um enum com switch é o suficiente e fácil de ler. Com estados que guardam dados próprios e têm muita lógica, o padrão State transforma cada estado numa classe que implementa uma interface comum. No Godot, uma variação popular é cada estado ser um nó filho de um nó "máquina de estados", com seu próprio script. A ideia é a mesma, e a escolha é de escala.

O fluxo do jogo

As telas do jogo também são uma máquina de estados, e ela merece um dono. Um autoload Fluxo centraliza as transições que antes estavam espalhadas em chamadas a ChangeSceneToFile:

public partial class Fluxo : Node
{
    public enum Tela { Menu, Jogando, Pausado, FimDeJogo }

    public Tela Atual { get; private set; } = Tela.Menu;

    [Signal] public delegate void TelaMudouEventHandler(int anterior, int nova);

    private static readonly Dictionary<Tela, Tela[]> Permitidas = new()
    {
        [Tela.Menu]      = [Tela.Jogando],
        [Tela.Jogando]   = [Tela.Pausado, Tela.FimDeJogo],
        [Tela.Pausado]   = [Tela.Jogando, Tela.Menu],
        [Tela.FimDeJogo] = [Tela.Menu, Tela.Jogando],
    };

    public void IrPara(Tela nova)
    {
        if (!Permitidas[Atual].Contains(nova))
        {
            GD.PushError($"Transição inválida: {Atual} → {nova}");
            return;
        }

        var anterior = Atual;
        Atual = nova;

        switch (nova)
        {
            case Tela.Menu:      GetTree().Paused = false; GetTree().ChangeSceneToFile("res://cenas/menu.tscn"); break;
            case Tela.Jogando when anterior == Tela.Pausado: GetTree().Paused = false; break;
            case Tela.Jogando:   GetTree().ChangeSceneToFile("res://cenas/principal.tscn"); break;
            case Tela.Pausado:   GetTree().Paused = true; break;
            case Tela.FimDeJogo: break;   // a própria partida mostra a sobreposição
        }

        EmitSignal(SignalName.TelaMudou, (int)anterior, (int)nova);
    }
}

A tabela de transições permitidas transforma bugs silenciosos em erros visíveis. Pausar a partir do menu, ou ir para o fim de jogo duas vezes (porque a última vida e a última traça chegaram no mesmo passo), passam a ser registrados em vez de produzirem um estado estranho. É o mesmo princípio dos estados do pedido na Fase 2: nem toda transição faz sentido, e o código deve dizer quais fazem.

Componentes: composição em vez de herança

A traça precisa de uma área que causa dano. Um futuro inimigo, a goteira que pinga do teto e danifica livros, também. A bibliotecária tem vidas e fica invencível depois de um golpe. Um futuro "livro de capa dura", que precisa ser apanhado duas vezes, também tem uma espécie de vida.

A tentação é criar uma hierarquia: Inimigo : Area2D, Traca : Inimigo, Goteira : Inimigo. Hierarquias de jogo quebram rápido, porque as combinações não respeitam árvores: a goteira causa dano e é destruída por livros, a traça causa dano e foge da luz. A alternativa, que os motores modernos adotam, é composição: comportamentos pequenos, cada um num nó, combinados livremente.

[GlobalClass]
public partial class ComponenteDeVida : Node
{
    [Export] public int Maxima { get; set; } = 3;
    [Export] public float Invencibilidade { get; set; } = 1.5f;

    [Signal] public delegate void MudouEventHandler(int atual, int maxima);
    [Signal] public delegate void AcabouEventHandler();

    public int Atual { get; private set; }
    public bool Invencivel => restante > 0;
    private float restante;

    public override void _Ready() => Atual = Maxima;

    public override void _PhysicsProcess(double delta) => restante -= (float)delta;

    public bool ReceberDano(int quantidade)
    {
        if (Invencivel || Atual <= 0) return false;
        Atual = Math.Max(0, Atual - quantidade);
        restante = Invencibilidade;
        EmitSignal(SignalName.Mudou, Atual, Maxima);
        if (Atual == 0) EmitSignal(SignalName.Acabou);
        return true;
    }

    public void Curar(int quantidade)
    {
        Atual = Math.Min(Maxima, Atual + quantidade);
        EmitSignal(SignalName.Mudou, Atual, Maxima);
    }
}
[GlobalClass]
public partial class CaixaDeDano : Area2D
{
    [Export] public int Dano { get; set; } = 1;

    public override void _Ready() => AreaEntered += AoEncostar;

    private void AoEncostar(Area2D outra)
    {
        if (outra is CaixaDeGolpe alvo)
            alvo.Receber(this);
    }
}

[GlobalClass]
public partial class CaixaDeGolpe : Area2D
{
    [Export] public ComponenteDeVida Vida { get; set; } = null!;

    [Signal] public delegate void AtingidaEventHandler(Vector2 origem);

    public void Receber(CaixaDeDano fonte)
    {
        if (Vida.ReceberDano(fonte.Dano))
            EmitSignal(SignalName.Atingida, fonte.GlobalPosition);
    }
}

[GlobalClass] registra o tipo no editor. Ele aparece na lista de "Adicionar Nó", com seus parâmetros exportados, e pode ser usado por qualquer cena. A traça passa a ser uma Area2D com um filho CaixaDeDano. A bibliotecária tem um ComponenteDeVida e uma CaixaDeGolpe apontando para ele. A goteira, quando existir, é outra cena com uma CaixaDeDano e nenhuma linha nova de código de dano. A invencibilidade do artigo anterior saiu da bibliotecária e foi para o componente, onde qualquer entidade com vida a aproveita.

É o mesmo princípio de "Um repositório, três bancos": depender de peças pequenas, com contratos claros, em vez de uma hierarquia que tenta prever todas as combinações. A literatura de jogos chama isso de padrão Component, e é a base do modelo de GameObject da Unity e dos nós do Godot.

Regras fora do motor

A pontuação ganhou uma regra nova: livros apanhados em sequência, sem deixar nenhum cair, aumentam um multiplicador, de 1× até 5×. Onde colocar essa regra?

Se ela ficar na Principal, só é possível testá-la jogando: abrir o jogo, apanhar dez livros em sequência e conferir o número na tela. Uma regra de pontuação é exatamente o tipo de coisa que se quer testar com xUnit, com dezenas de casos, em milissegundos. A solução é a mesma das Fases 4 e 5: um projeto de regras sem dependência do motor.

jogos/BibliotecaEmApuros/
├── BibliotecaEmApuros.csproj         ← projeto Godot (cenas, nós, scripts)
├── Regras/
│   ├── BibliotecaEmApuros.Regras.csproj   ← biblioteca de classes .NET comum
│   └── RegrasDaPartida.cs
└── Testes/
    └── BibliotecaEmApuros.Testes.csproj   ← xUnit, referencia só Regras
namespace BibliotecaEmApuros.Regras;

public sealed class RegrasDaPartida
{
    public const int VidasIniciais = 3;
    private const int LivrosPorNivelDeMultiplicador = 10;
    private const int MultiplicadorMaximo = 5;

    public int Pontos { get; private set; }
    public int Vidas { get; private set; } = VidasIniciais;
    public int Sequencia { get; private set; }
    public float Tempo { get; private set; }
    public bool Terminou => Vidas <= 0;

    public int Multiplicador => Math.Min(1 + Sequencia / LivrosPorNivelDeMultiplicador, MultiplicadorMaximo);
    public float Dificuldade => 1f + Tempo / 30f;

    public event Action? Mudou;
    public event Action? Acabou;

    public void Avancar(float dt)
    {
        if (!Terminou) Tempo += dt;
    }

    public int LivroApanhado(bool raro)
    {
        if (Terminou) return 0;
        Sequencia++;
        var ganho = (raro ? 50 : 10) * Multiplicador;
        Pontos += ganho;
        Mudou?.Invoke();
        return ganho;
    }

    public void LivroPerdido()
    {
        Sequencia = 0;
        PerderVida();
    }

    public void AtingidaPorTraca() => PerderVida();

    public void MarcadorApanhado()
    {
        if (Vidas < VidasIniciais) Vidas++;
        Mudou?.Invoke();
    }

    private void PerderVida()
    {
        if (Terminou) return;
        Vidas--;
        Mudou?.Invoke();
        if (Terminou) Acabou?.Invoke();
    }
}
public sealed class RegrasDaPartidaTestes
{
    [Fact]
    public void Multiplicador_sobe_a_cada_dez_livros_seguidos_e_para_em_cinco()
    {
        var regras = new RegrasDaPartida();
        for (var i = 0; i < 9; i++) regras.LivroApanhado(raro: false);
        Assert.Equal(1, regras.Multiplicador);

        regras.LivroApanhado(raro: false);
        Assert.Equal(2, regras.Multiplicador);

        for (var i = 0; i < 100; i++) regras.LivroApanhado(raro: false);
        Assert.Equal(5, regras.Multiplicador);
    }

    [Fact]
    public void Livro_perdido_zera_a_sequencia_e_tira_uma_vida()
    {
        var regras = new RegrasDaPartida();
        for (var i = 0; i < 15; i++) regras.LivroApanhado(raro: false);

        regras.LivroPerdido();

        Assert.Equal(0, regras.Sequencia);
        Assert.Equal(1, regras.Multiplicador);
        Assert.Equal(RegrasDaPartida.VidasIniciais - 1, regras.Vidas);
    }

    [Fact]
    public void Nada_pontua_depois_do_fim()
    {
        var regras = new RegrasDaPartida();
        for (var i = 0; i < 3; i++) regras.AtingidaPorTraca();

        Assert.Equal(0, regras.LivroApanhado(raro: true));
    }
}

A Principal vira um adaptador: traduz sinais do motor em chamadas às regras, e eventos das regras em mudanças de nós:

public override void _Ready()
{
    regras.Mudou += () => Hud.Atualizar(regras.Pontos, regras.Vidas, regras.Multiplicador);
    regras.Acabou += () => Fluxo.IrPara(Fluxo.Tela.FimDeJogo);
    Bibliotecaria.Vida.Mudou += (_, _) => regras.AtingidaPorTraca();
}

public override void _PhysicsProcess(double delta) => regras.Avancar((float)delta);

A separação é a mesma que a Livraria fez entre domínio e infraestrutura em "Um repositório, três bancos", e que "Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos" vai formalizar mais adiante. O motor é infraestrutura: desenha, detecta colisões e toca sons. As regras que definem o jogo não dependem dele e podem ser testadas, reaproveitadas num servidor (o capstone vai precisar validar pontuações) e mudadas sem abrir o editor.

A tentação oposta também existe: tentar tirar tudo do motor, com a física, o movimento e as colisões numa simulação própria. Para a maioria dos jogos, isso é trabalho demais. A linha prática é: regras de jogo (pontuação, progressão, economia, condições de vitória) ficam fora, e regras de movimento e colisão ficam no motor, que é bom nisso.

Um barramento de eventos, com moderação

Sons, estatísticas e conquistas querem saber de quase tudo: livro apanhado, livro perdido, pulo, golpe, recorde. Conectar cada sistema a cada emissor cria uma teia. Um autoload com sinais globais, um barramento de eventos, simplifica:

public partial class Eventos : Node
{
    [Signal] public delegate void LivroApanhadoEventHandler(Vector2 posicao, bool raro, int ganho);
    [Signal] public delegate void BibliotecariaAtingidaEventHandler(Vector2 posicao);
    [Signal] public delegate void RecordeBatidoEventHandler(int pontos);
}

O Livro emite Eventos.LivroApanhado, e o sistema de áudio, as partículas e o contador de estatísticas escutam, sem que ninguém conheça ninguém. É conveniente, e tem o custo de todo estado global: fica difícil saber quem emite e quem escuta um evento, e a ordem em que os ouvintes rodam não é garantida. A regra prática é usar o barramento para reações secundárias (som, efeitos, estatísticas) e manter os fluxos principais (pontuação, vida, fim de jogo) com ligações diretas e explícitas, fáceis de seguir no código.

Por que não alocar dentro do loop

Agora, a pergunta que "C# nos jogos" deixou em aberto. Na Fase 4, "Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable" explicou o coletor geracional do .NET: objetos novos nascem na geração 0, que é coletada quando seu orçamento enche. A maioria morre jovem e a coleta é rápida. "Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar" mostrou como evitar alocações. Na API, uma coleta de geração 0 de meio milissegundo a cada poucos segundos não aparece em lugar nenhum. No jogo, precisamos saber quanto ela custa dentro de um quadro de 16,67 ms.

Medindo, antes de acreditar

Um nó de diagnóstico mostra os números relevantes na tela:

public partial class MonitorDeMemoria : Label
{
    private long alocadoNoInicio;
    private int coletasNoInicio;
    private TimeSpan pausaNoInicio;
    private int quadros;

    public override void _Ready() => Reiniciar();

    public override void _Process(double delta)
    {
        if (++quadros < 60) return;                        // atualiza uma vez por segundo

        var bytesPorQuadro = (GC.GetTotalAllocatedBytes() - alocadoNoInicio) / quadros;
        var coletas = GC.CollectionCount(0) - coletasNoInicio;
        var pausa = GC.GetTotalPauseDuration() - pausaNoInicio;

        Text = $"alocado/quadro: {bytesPorQuadro} B\ncoletas gen0/s: {coletas}\npausa GC/s: {pausa.TotalMilliseconds:F2} ms";
        Reiniciar();
    }

    private void Reiniciar()
    {
        alocadoNoInicio = GC.GetTotalAllocatedBytes();
        coletasNoInicio = GC.CollectionCount(0);
        pausaNoInicio = GC.GetTotalPauseDuration();
        quadros = 0;
    }
}

GC.GetTotalPauseDuration, disponível desde o .NET 7, soma o tempo em que o coletor parou as threads da aplicação. Esse é o número que o jogador sente. O próprio monitor só aloca uma string por segundo, para não se tornar o problema que está medindo. Com ele no canto da tela (e só em builds de depuração), cada mudança no código tem um custo visível.

De onde vêm as alocações escondidas

Rodando a "Biblioteca em Apuros" com o monitor, alguns culpados comuns aparecem, e eles são típicos de C# em motores de jogos:

Código O que aloca Alternativa
GetNode<Sprite2D>("Sprite") em _Process conversão da string para NodePath a cada chamada buscar uma vez em _Ready, ou [Export] da referência
Input.IsActionPressed("pular") conversão para StringName static readonly StringName
label.Text = $"Pontos: {p}" a cada quadro uma string por quadro atualizar só quando o valor muda
GetTree().GetNodesInGroup("livros") uma Godot.Collections.Array nova por chamada manter uma List<T> própria, atualizada ao criar e remover
livros.Where(l => l.Raro).Count() enumerador e closure do LINQ um for simples
foreach numa Godot.Collections.Array conversões de Variant por elemento coleções .NET para dados só do C#
new Vector2(...) nada: Vector2 é struct não há o que mudar
CenaDoLivro.Instantiate<Livro>() + QueueFree() uma árvore de nós inteira, e o lado nativo pool de nós

A última linha merece uma seção à parte.

Pool de nós

No protótipo MonoGame, os livros eram structs num arranjo pré-alocado. No Godot, cada livro é uma árvore de nós (a Area2D, o sprite, a forma de colisão), com objetos gerenciados e nativos do lado do motor em C++. Instanciar e liberar a cada livro custa bem mais que ativar uma struct, e o custo cresce com o número de entidades: com a dificuldade alta, livros, traças, livros caídos quicando e partículas de poeira somam dezenas de criações e destruições por segundo.

O pool reaproveita os nós:

public interface IReutilizavel
{
    void AoRetirar();
    void AoDevolver();
}

public sealed class PoolDeNos<T> where T : Node2D, IReutilizavel
{
    private readonly PackedScene cena;
    private readonly Node pai;
    private readonly Stack<T> livres = new();

    public PoolDeNos(PackedScene cena, Node pai, int preaquecer, Action<T>? aoCriar = null)
    {
        (this.cena, this.pai, this.aoCriar) = (cena, pai, aoCriar);
        for (var i = 0; i < preaquecer; i++)
        {
            var no = Criar();
            Desativar(no);
            livres.Push(no);
        }
    }

    private readonly Action<T>? aoCriar;

    public T Retirar()
    {
        var no = livres.Count > 0 ? livres.Pop() : Criar();
        no.Visible = true;
        no.ProcessMode = Node.ProcessModeEnum.Inherit;
        no.AoRetirar();
        return no;
    }

    public void Devolver(T no)
    {
        no.AoDevolver();
        Desativar(no);
        livres.Push(no);
    }

    private T Criar()
    {
        var no = cena.Instantiate<T>();
        pai.AddChild(no);
        aoCriar?.Invoke(no);            // assinaturas de sinais: UMA vez por nó
        return no;
    }

    private static void Desativar(T no)
    {
        no.Visible = false;
        no.ProcessMode = Node.ProcessModeEnum.Disabled;   // para processamento e física
        no.Position = new Vector2(-1000, -1000);
    }
}

ProcessMode = Disabled interrompe _Process e _PhysicsProcess, e para objetos de colisão a propriedade DisableMode (por padrão, "remover") faz o motor tirar a forma da simulação enquanto o nó está desativado. Um livro guardado no pool não detecta colisões nem gasta tempo de física. O Livro implementa IReutilizavel e, em AoRetirar, zera o próprio estado: velocidade, raridade, textura. Todo o estado precisa ser reiniciado, porque um livro raro devolvido e retirado de novo como livro comum continuaria dourado se alguém esquecesse de trocar a textura.

O parâmetro aoCriar resolve uma armadilha que o pool introduz. Antes, a Principal conectava livro.Apanhado += AoApanharLivro para cada livro instanciado, e o livro morria depois. Com o pool, se a conexão for feita a cada Retirar, o mesmo livro acumula assinaturas: na décima reutilização, apanhá-lo soma pontos dez vezes. A conexão passa a ser feita uma vez, na criação.

O pool é pré-aquecido na carga da partida, com o número de livros que a dificuldade máxima costuma ter na tela. Assim a criação acontece durante a tela de carregamento, e não no meio da partida.

Quando os objetos são milhares: orientação a dados

Pools resolvem dezenas de entidades. Para milhares, como a poeira de páginas que se espalha quando um livro raro é apanhado, nem um nó por partícula compensa. Cada nó é um objeto com dezenas de campos, espalhado pela memória, com o custo de interoperação a cada acesso. Para o processador, iterar mil nós é pular de endereço em endereço, perdendo o cache a cada passo.

A alternativa é a orientação a dados: guardar os dados de todas as partículas em arranjos contínuos de structs, atualizá-las num laço apertado e desenhá-las todas de uma vez:

public partial class PoeiraDePaginas : Node2D
{
    private const int Capacidade = 4096;

    private struct Particula
    {
        public Vector2 Posicao;
        public Vector2 Velocidade;
        public float Vida;
    }

    private readonly Particula[] particulas = new Particula[Capacidade];
    private int ativas;

    public void Explodir(Vector2 origem, int quantidade, RandomNumberGenerator sorteio)
    {
        for (var i = 0; i < quantidade && ativas < Capacidade; i++)
        {
            particulas[ativas++] = new Particula
            {
                Posicao = origem,
                Velocidade = Vector2.FromAngle(sorteio.Randf() * Mathf.Tau) * sorteio.RandfRange(20, 90),
                Vida = sorteio.RandfRange(0.4f, 0.9f)
            };
        }
    }

    public override void _Process(double delta)
    {
        var dt = (float)delta;
        var span = particulas.AsSpan(0, ativas);

        for (var i = 0; i < span.Length; i++)
        {
            ref var p = ref span[i];
            p.Vida -= dt;
            if (p.Vida <= 0)
            {
                span[i] = span[--ativas];           // troca com a última ativa: remoção O(1)
                span = particulas.AsSpan(0, ativas);
                i--;
                continue;
            }
            p.Velocidade.Y += 120f * dt;
            p.Posicao += p.Velocidade * dt;
        }

        QueueRedraw();
    }

    public override void _Draw()
    {
        foreach (ref readonly var p in particulas.AsSpan(0, ativas))
            DrawRect(new Rect2(p.Posicao, new Vector2(2, 2)), new Color(0.95f, 0.9f, 0.75f, p.Vida));
    }
}

Um único nó cuida de todas as partículas. Os dados ficam contíguos na memória e a atualização percorre-os em sequência, o padrão de acesso que os processadores fazem mais rápido. A remoção por troca com o último elemento mantém as ativas no começo do arranjo, sem buracos e sem deslocar nada. Nenhuma alocação acontece depois da criação do arranjo. Para partículas puramente visuais, o próprio Godot oferece GPUParticles2D, que roda tudo na placa de vídeo, e em geral é a melhor escolha. O exemplo acima vale quando as partículas precisam de lógica em C#, como interagir com as regras do jogo.

Levada ao extremo, essa ideia é o ECS (Entity Component System): entidades são só identificadores, componentes são structs guardadas em arranjos por tipo, e sistemas são funções que percorrem todas as entidades que têm certos componentes. A Unity o oferece no pacote DOTS. No ecossistema .NET, bibliotecas como Arch, Friflo.Engine.ECS e DefaultEcs implementam o modelo com desempenho próximo ao de C++, usando os mesmos Span, ref e structs que o curso apresentou. Para a "Biblioteca em Apuros", com dezenas de entidades, nós com pools bastam. ECS se paga em jogos com milhares de entidades com comportamento: simuladores, estratégia em tempo real, bullet hells.

E se ainda assim houver coletas

Com as alocações contidas, o coletor quase não roda durante a partida. Dois recursos ajudam nos casos restantes:

  • GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency pede ao coletor que evite coletas completas e bloqueantes enquanto possível, preferindo coletas em segundo plano. Ativa-se ao iniciar a partida e se restaura ao voltar ao menu.
  • Coletar nos momentos certos: chamar GC.Collect() durante uma transição de tela, com a tela de carregamento visível, limpa o lixo da fase anterior num momento em que uma pausa não incomoda ninguém. Fora desses momentos, chamar o coletor manualmente quase sempre piora as coisas.

O ponto central continua sendo o do começo: medir. O profiler do Godot mostra o tempo de cada função por quadro, e dotnet-counters e dotnet-trace, anexados ao processo do jogo, mostram a taxa de alocação e as coletas em detalhe. Otimizar alocações que não aparecem na medição é tempo perdido. Deixar de medir é descobrir o problema pelo relato de um jogador.

A "Biblioteca em Apuros" agora tem arquitetura: a personagem e o fluxo de telas são máquinas de estados explícitas, dano e vida são componentes reutilizáveis, as regras da partida moram num projeto testável que nem sabe que o Godot existe, os efeitos secundários escutam um barramento de eventos, e livros, traças e poeira são reaproveitados, com o monitor na tela confirmando que o loop não gera lixo. Falta o que transforma um jogo funcional num jogo agradável: som, uma interface completa com menu, opções e pausa, os pequenos efeitos que dão peso a cada ação, e um save que guarda recordes e preferências sem perder nada se o jogo fechar no meio da gravação.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Acrescente à bibliotecária o estado Escorregando: ao pousar em cima de um livro caído (um RigidBody2D no chão), ela escorrega por 0,5 s sem controle, mas pode pular. Descreva as transições de e para esse estado e o que muda em AoEntrar e AoSair.

Ver resposta

✓ Resposta: Transições de entrada: de NoAr para Escorregando quando pousa e o objeto sob os pés é um livro caído (verificado com GetSlideCollision e o tipo do colisor, ou com uma camada "livros_caidos" consultada por um RayCast2D curto apontado para baixo); de NoChao para Escorregando quando pisa num livro caído ao andar. Saídas: para NoAr se o buffer de pulo estiver ativo (pular é permitido); para NoChao quando tempoNoEstado >= 0.5f; para Atordoada se atingida. No AtualizarEscorregando, a velocidade horizontal mantém o valor da entrada no estado com atrito muito baixo (MoveToward com atrito pequeno) e ignora GetAxis. AoEntrar(Escorregando): tocar a animação "escorregando", um som de papel e partículas de poeira. AoSair(Escorregando): parar o som em laço, se houver. A máquina de estados evita que "escorregando" precise de if espalhados em NoChao e NoAr.

Exercício 2

Com o pool de livros, um jogador percebe que alguns livros comuns aparecem dourados e valem 50 pontos. Qual é o bug provável, e que regra de implementação de AoRetirar o evita?

Ver resposta

✓ Resposta: O livro foi retirado do pool como raro, recebeu a textura dourada e o valor 50, foi devolvido, e ao ser retirado de novo como comum só a propriedade Raro foi trocada. A textura, que era definida em _Ready (chamado uma única vez, na criação) ou em algum ponto condicional, continuou dourada, e o valor pode ter ficado guardado num campo que ninguém reiniciou. A regra: AoRetirar (ou um método Configurar(raro, velocidade, posicao) chamado logo depois) deve definir todo o estado observável do objeto, sem depender de valores anteriores: textura, valor, velocidade, escala, cor (Modulate, que pode ter ficado transparente por um efeito de sumiço), estado de animação e flags internas. Um teste útil: retirar, configurar como raro, devolver, retirar, configurar como comum e verificar cada propriedade.

Exercício 3

O monitor mostra 0 bytes por quadro na maior parte do tempo, mas picos de 40 KB quando muitos livros são apanhados seguidos. O pool está ativo. Liste onde procurar e como confirmar.

Ver resposta

✓ Resposta: Os picos coincidem com eventos, não com o laço normal, então os suspeitos estão nos tratadores do evento "livro apanhado": (1) o barramento Eventos.LivroApanhado com argumentos que o Godot converte em Variant, somado a vários ouvintes; (2) o sistema de áudio instanciando um AudioStreamPlayer novo por som em vez de reutilizar; (3) a interface formatando o texto da pontuação e do multiplicador a cada livro; (4) uma animação de pontos flutuantes ("+50") instanciada por cena a cada livro, sem pool; (5) CreateTween() a cada evento, que cria um objeto Tween por chamada, aceitável isoladamente, mas somado aos outros; (6) partículas GPUParticles2D instanciadas em vez de reaproveitadas. Para confirmar: desligar os ouvintes um de cada vez e observar o pico, ou usar dotnet-trace com o provedor de alocações do runtime durante uma sequência de apanhadas, que aponta a pilha das alocações. A correção típica é pool também para os efeitos (texto flutuante, partículas) e textos atualizados só uma vez por quadro, mesmo que vários livros tenham sido apanhados nele.

Exercício 4

Por que as regras da partida ficam num projeto separado, e não numa pasta Regras/ dentro do projeto Godot? O que exatamente o compilador passa a garantir?

Ver resposta

✓ Resposta: Uma pasta dentro do projeto Godot compila junto com os scripts, com acesso a using Godot; e a todos os tipos do motor. Nada impede que, um dia, alguém use GD.Print, um Vector2 do Godot ou GetTree() dentro das regras, e a partir daí elas deixam de ser testáveis sem o motor e de ser reaproveitáveis. Num projeto de biblioteca separado, que não referencia o GodotSharp, o compilador recusa qualquer uso do motor: a separação deixa de ser uma convenção e vira uma garantia. O projeto de testes, por sua vez, referencia só as regras e roda em qualquer máquina de CI, sem instalar o Godot. E o capstone pode referenciar a mesma biblioteca na API da Livraria para validar pontuações com as regras exatas do jogo. É o mesmo raciocínio que separou Livraria.Dominio da infraestrutura em "Um repositório, três bancos".

Exercício 5

Estime a diferença de uso de memória e de trabalho por quadro entre 2.000 partículas de poeira como nós Sprite2D e como o arranjo de structs do artigo. Depois, meça, e explique por que a diferença de tempo tende a ser maior que a de memória.

Ver resposta

✓ Resposta: Memória: cada Particula tem dois Vector2 e um float, 20 bytes, e 2.000 ocupam cerca de 40 KB contíguos. Cada Sprite2D é um objeto gerenciado com cabeçalho e campos, mais o objeto nativo correspondente no motor, com transformações, propriedades de desenho e registro na árvore, facilmente algumas centenas de bytes a alguns kilobytes por nó somando os dois lados. Tempo: o arranjo é percorrido num laço sequencial, sem chamadas ao motor até o desenho, e o desenho é um único nó emitindo 2.000 retângulos. Com nós, são 2.000 chamadas a _Process (cada uma atravessando a fronteira C#/C++), 2.000 atualizações de Position que também atravessam a fronteira e invalidam transformações, e 2.000 itens para a árvore de desenho percorrer. A diferença de tempo é maior que a de memória porque o custo dominante não é o espaço, é o padrão de acesso: dados espalhados causam perdas de cache a cada objeto, e cada chamada de interoperação tem custo fixo. Para medir, instancie as duas versões com o profiler do Godot aberto e compare o tempo de processo por quadro, e o monitor de memória para as alocações.

Comentários

Mais em Linguagem C#

ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection
ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection

Como o C# conversa com o banco pelo ADO.NET: conexões e pool, ExecuteNonQuery…

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…

MongoDB Avançado: Agregações, Índices e a Escolha Entre Documento e Relacional
MongoDB Avançado: Agregações, Índices e a Escolha Entre Documento e Relacional

O MongoDB além do básico: pipeline de agregação em LINQ, $lookup, índices…