Um game loop do zero com MonoGame: janela, sprites, entrada e os primeiros livros caindo

Um game loop do zero com MonoGame: janela, sprites, entrada e os primeiros livros caindo

O primeiro protótipo da Biblioteca em Apuros em MonoGame, escrito à mão: content pipeline com acentos na fonte, a classe Game com passo fixo, tela virtual de 480×270 para pixel art nítida, teclado por comparação de estados, um pool de structs para a chuva de livros e a medição do que o loop aloca.
Linguagem C#

• • 20 min de leitura

O artigo anterior explicou o game loop em teoria. Agora vamos construí-lo. Com o MonoGame, o framework mais próximo do metal entre as opções em C#, o primeiro protótipo da "Biblioteca em Apuros" ganha janela, imagens, teclado, livros caindo, pontuação e fim de partida, e cada peça fica à mostra.

Por que começar pelo framework

Um motor como o Godot entrega física, cenas e editor prontos, e isso é ótimo para produzir. Para aprender, esconde demais: quando o livro atravessa a bibliotecária sem ser apanhado, é difícil saber se o problema está no seu código ou numa configuração do motor. No MonoGame não há mágica. Se o livro cai, é porque um Update somou velocidade à posição. Se ele é apanhado, é porque um teste de retângulos deu verdadeiro. O que construirmos aqui vai ajudar a reconhecer, no artigo seguinte, o que o Godot faz por nós.

O projeto

dotnet new install MonoGame.Templates.CSharp
dotnet new mgdesktopgl -n BibliotecaEmApuros -o jogos/BibliotecaEmApuros
cd jogos/BibliotecaEmApuros
dotnet tool restore
dotnet run

O template mgdesktopgl usa OpenGL via SDL e roda em Windows, macOS e Linux com o mesmo código. Existem templates para DirectX (só Windows), Android e iOS. O dotnet tool restore instala as ferramentas do content pipeline, declaradas no manifesto .config/dotnet-tools.json do projeto. O primeiro dotnet run abre uma janela azul, a cor CornflowerBlue, que é a saudação tradicional do XNA há vinte anos.

A estrutura:

BibliotecaEmApuros/
├── Content/
│   └── Content.mgcb        ← lista de recursos a processar
├── Game1.cs                ← o jogo (vamos renomear)
├── Program.cs              ← using var jogo = new Game1(); jogo.Run();
└── BibliotecaEmApuros.csproj

O content pipeline

Um jogo carrega imagens, fontes e sons. O MonoGame os processa em tempo de build: o Content.mgcb lista os arquivos-fonte (PNG, WAV, descrições de fonte), e a ferramenta MGCB os converte para o formato .xnb, já otimizado para a GPU e a plataforma de destino. Em tempo de execução, Content.Load<T> carrega o arquivo processado:

dotnet mgcb-editor Content/Content.mgcb

O editor visual adiciona os arquivos. Para o protótipo usamos quatro:

  • bibliotecaria.png (16×24 pixels)
  • livro.png e livro_raro.png (12×16 pixels)
  • Fonte.spritefont, um XML que descreve qual fonte do sistema usar e em que tamanho. O pipeline a transforma numa textura com os caracteres pré-renderizados.

A fonte precisa incluir os caracteres acentuados do português. A região de caracteres padrão da descrição cobre só o ASCII (32 a 126), e "Pontuação" apareceria com um buraco no lugar do "ç". Acrescenta-se a faixa <Start>&#160;</Start><End>&#255;</End>, que cobre o Latin-1. É o mesmo cuidado com codificação de "Arquivos, Streams e JSON", num lugar inesperado.

Para protótipos também é possível pular o pipeline e carregar PNGs diretamente com Texture2D.FromStream(GraphicsDevice, fluxo). É conveniente, mas perde as otimizações e a pré-multiplicação de transparência que o pipeline faz.

A classe do jogo

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

public sealed class BibliotecaEmApuros : Game
{
    public const int Largura = 480;
    public const int Altura = 270;

    private readonly GraphicsDeviceManager graficos;
    private SpriteBatch pincel = null!;
    private RenderTarget2D telaVirtual = null!;
    private Recursos recursos = null!;
    private readonly Teclado teclado = new();

    private Estado estado = Estado.Menu;
    private Partida partida = null!;
    private int recorde;

    public BibliotecaEmApuros()
    {
        graficos = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsFixedTimeStep = true;
        TargetElapsedTime = TimeSpan.FromSeconds(1.0 / 60.0);
        graficos.SynchronizeWithVerticalRetrace = true;
        IsMouseVisible = false;
    }

    protected override void Initialize()
    {
        Window.Title = "Biblioteca em Apuros";
        Window.AllowUserResizing = true;
        graficos.PreferredBackBufferWidth = Largura * 3;
        graficos.PreferredBackBufferHeight = Altura * 3;
        graficos.ApplyChanges();
        base.Initialize();
    }

    protected override void LoadContent()
    {
        pincel = new SpriteBatch(GraphicsDevice);
        telaVirtual = new RenderTarget2D(GraphicsDevice, Largura, Altura);
        recursos = Recursos.Carregar(Content, GraphicsDevice);
        partida = new Partida(recursos, semente: Environment.TickCount);
    }

    protected override void Update(GameTime tempo)
    {
        teclado.Atualizar();
        if (teclado.Pressionou(Keys.Escape)) Exit();

        var dt = (float)tempo.ElapsedGameTime.TotalSeconds;

        switch (estado)
        {
            case Estado.Menu when teclado.Pressionou(Keys.Enter):
                partida.Reiniciar(Environment.TickCount);
                estado = Estado.Jogando;
                break;

            case Estado.Jogando:
                partida.Atualizar(dt, teclado);
                if (partida.Terminou)
                {
                    recorde = Math.Max(recorde, partida.Pontos);
                    estado = Estado.FimDeJogo;
                }
                break;

            case Estado.FimDeJogo when teclado.Pressionou(Keys.Enter):
                estado = Estado.Menu;
                break;
        }

        base.Update(tempo);
    }

    protected override void Draw(GameTime tempo)
    {
        GraphicsDevice.SetRenderTarget(telaVirtual);
        GraphicsDevice.Clear(new Color(0xF2, 0xE6, 0xD0));

        pincel.Begin(samplerState: SamplerState.PointClamp);
        switch (estado)
        {
            case Estado.Menu:      Telas.DesenharMenu(pincel, recursos, recorde); break;
            case Estado.Jogando:   partida.Desenhar(pincel); break;
            case Estado.FimDeJogo: partida.Desenhar(pincel); Telas.DesenharFim(pincel, recursos, partida.Pontos, recorde); break;
        }
        pincel.End();

        GraphicsDevice.SetRenderTarget(null);
        GraphicsDevice.Clear(Color.Black);
        pincel.Begin(samplerState: SamplerState.PointClamp);
        pincel.Draw(telaVirtual, AreaDeExibicao(), Color.White);
        pincel.End();

        base.Draw(tempo);
    }

    private Rectangle AreaDeExibicao()
    {
        var janela = GraphicsDevice.PresentationParameters.Bounds;
        var escala = Math.Max(1, Math.Min(janela.Width / Largura, janela.Height / Altura));
        var (w, h) = (Largura * escala, Altura * escala);
        return new Rectangle((janela.Width - w) / 2, (janela.Height - h) / 2, w, h);
    }
}

public enum Estado { Menu, Jogando, FimDeJogo }

Vamos por partes.

O ciclo de vida da classe Game. O construtor configura, Initialize roda uma vez depois que o dispositivo gráfico existe, LoadContent carrega recursos, e então o framework entra no loop chamando Update e Draw. Com IsFixedTimeStep = true, o Update é chamado em passos de exatamente 1/60 s, tantas vezes quantas forem necessárias para acompanhar o relógio, e o ElapsedGameTime é sempre esse valor fixo. É o acumulador de "C# nos jogos", pronto dentro do framework.

O SynchronizeWithVerticalRetrace (vsync) faz a troca de quadros esperar a atualização do monitor. Isso evita o tearing, a linha horizontal de corte que aparece quando a imagem muda no meio de uma varredura da tela. Combinado com o passo fixo, a 60 Hz cada volta tem um Update e um Draw. Num monitor de 144 Hz, algumas voltas não têm Update. O MonoGame não interpola o desenho automaticamente como o loop do artigo anterior fazia. Para um jogo de pixel art a 60 passos por segundo, a diferença é imperceptível, e mantemos a simplicidade.

A tela virtual. O jogo é desenhado sempre numa textura de 480×270 pixels, um render target, que depois é ampliada para a janela por um fator inteiro, com barras pretas nas sobras. A lógica do jogo trabalha em coordenadas fixas, independentemente do monitor. O fator inteiro e o SamplerState.PointClamp (sem suavização entre pixels) mantêm a pixel art nítida, sem borrões. 480×270 é exatamente 1/4 de Full HD e 1/8 de 4K, e amplia sem distorção nas resoluções mais comuns.

Os estados do jogo são um enum e um switch. Três telas cabem nisso com folga. Em "Arquitetura de jogo e por que não alocar dentro do loop" essa estrutura vira uma máquina de estados de verdade.

Entrada: estado atual e estado anterior

O MonoGame lê o teclado por amostragem: Keyboard.GetState() devolve quais teclas estão pressionadas agora. Para saber se uma tecla foi acabada de pressionar (Enter para começar, sem disparar trinta vezes enquanto o dedo está na tecla), é preciso comparar com o quadro anterior:

public sealed class Teclado
{
    private KeyboardState atual, anterior;

    public void Atualizar()
    {
        anterior = atual;
        atual = Keyboard.GetState();
    }

    public bool Segurando(Keys tecla) => atual.IsKeyDown(tecla);
    public bool Pressionou(Keys tecla) => atual.IsKeyDown(tecla) && anterior.IsKeyUp(tecla);
    public bool Soltou(Keys tecla) => atual.IsKeyUp(tecla) && anterior.IsKeyDown(tecla);

    public float Eixo(Keys negativo, Keys positivo) =>
        (Segurando(positivo) ? 1f : 0f) - (Segurando(negativo) ? 1f : 0f);
}

KeyboardState é uma struct: guardar o estado anterior é uma cópia, sem alocação. A comparação entre dois estados é o padrão universal de entrada em jogos, e o Godot o oferece pronto como IsActionJustPressed. O Eixo transforma duas teclas num valor entre −1 e 1, que é o formato que um analógico de controle também produz. Assim o código de movimento não precisa saber de onde veio a entrada.

A partida

public sealed class Partida
{
    private const float VelocidadeDaBibliotecaria = 180f;   // pixels por segundo
    private const float Chao = BibliotecaEmApuros.Altura - 20;

    private readonly Recursos recursos;
    private readonly ChuvaDeLivros chuva;
    private Vector2 posicao;
    private float tempoDePartida;
    private int pontosExibidos = -1;
    private string textoDosPontos = "";

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

    public Partida(Recursos recursos, int semente)
    {
        this.recursos = recursos;
        chuva = new ChuvaDeLivros(capacidade: 64);
        Reiniciar(semente);
    }

    public void Reiniciar(int semente)
    {
        posicao = new Vector2(BibliotecaEmApuros.Largura / 2f, Chao - recursos.Bibliotecaria.Height);
        tempoDePartida = 0;
        Pontos = 0;
        Vidas = 3;
        chuva.Reiniciar(semente);
    }

    public void Atualizar(float dt, Teclado teclado)
    {
        tempoDePartida += dt;
        var dificuldade = 1f + tempoDePartida / 30f;       // +100% a cada 30 s

        posicao.X += teclado.Eixo(Keys.Left, Keys.Right) * VelocidadeDaBibliotecaria * dt;
        posicao.X = MathHelper.Clamp(posicao.X, 0, BibliotecaEmApuros.Largura - recursos.Bibliotecaria.Width);

        var areaDeCaptura = new Rectangle((int)posicao.X, (int)posicao.Y,
                                          recursos.Bibliotecaria.Width, 8);   // só as mãos

        var resultado = chuva.Atualizar(dt, dificuldade, areaDeCaptura, Chao);
        Pontos += resultado.Pontos;
        Vidas -= resultado.Perdidos;
    }

    public void Desenhar(SpriteBatch pincel)
    {
        pincel.Draw(recursos.Pixel, new Rectangle(0, (int)Chao, BibliotecaEmApuros.Largura, 20),
                    new Color(0x8B, 0x5A, 0x2B));
        chuva.Desenhar(pincel, recursos);
        pincel.Draw(recursos.Bibliotecaria, posicao, Color.White);

        if (Pontos != pontosExibidos)
        {
            textoDosPontos = $"Pontos: {Pontos}";
            pontosExibidos = Pontos;
        }
        pincel.DrawString(recursos.Fonte, textoDosPontos, new Vector2(8, 6), Color.SaddleBrown);

        for (var i = 0; i < Vidas; i++)
            pincel.Draw(recursos.Coracao, new Vector2(BibliotecaEmApuros.Largura - 16 - i * 14, 6), Color.White);
    }
}

Três decisões aqui são de jogo e não de programação, e mostram como as duas coisas se misturam.

A área de captura é menor que o sprite: só uma faixa de 8 pixels na altura das mãos. Se o retângulo inteiro da bibliotecária apanhasse livros, um livro que batesse na cabeça dela contaria como apanhado, e o jogador sentiria que o jogo é frouxo. Hitboxes ajustadas ao que o jogador espera são uma das diferenças entre um jogo que "parece certo" e um que não parece. Voltaremos a isso em "Input, física e colisões".

A dificuldade cresce com o tempo, linearmente. A curva de dificuldade é talvez o parâmetro mais importante do jogo, e ela está numa única linha, fácil de ajustar e de testar.

O texto da pontuação só é recriado quando a pontuação muda. A interpolação $"Pontos: {Pontos}" aloca uma string nova. Feita a cada quadro, seriam 60 strings por segundo, 3.600 por minuto, todas lixo imediato, para mostrar um número que muda poucas vezes por minuto. Guardando o último valor exibido, a alocação só acontece quando há algo novo para mostrar. É o hábito de "C# nos jogos" em sua forma mais simples.

A chuva de livros: um pool de structs

Os livros nascem, caem e desaparecem sem parar. Criar um objeto novo com new Livro() a cada livro e deixá-lo para o coletor seria o caminho natural numa API. Aqui usamos um pool: um arranjo de tamanho fixo, alocado uma vez, em que cada posição é ativada e desativada:

public struct Livro
{
    public Vector2 Posicao;
    public float Velocidade;
    public bool Raro;
    public bool Ativo;
}

public readonly record struct ResultadoDaChuva(int Pontos, int Perdidos);

public sealed class ChuvaDeLivros(int capacidade)
{
    private readonly Livro[] livros = new Livro[capacidade];
    private Random sorteio = new(0);
    private float ateOProximo;

    public void Reiniciar(int semente)
    {
        Array.Clear(livros);
        sorteio = new Random(semente);
        ateOProximo = 1f;
    }

    public ResultadoDaChuva Atualizar(float dt, float dificuldade, Rectangle captura, float chao)
    {
        ateOProximo -= dt;
        if (ateOProximo <= 0)
        {
            Soltar(dificuldade);
            ateOProximo = MathF.Max(0.25f, 1.2f / dificuldade) * (0.7f + (float)sorteio.NextDouble() * 0.6f);
        }

        int pontos = 0, perdidos = 0;

        for (var i = 0; i < livros.Length; i++)
        {
            ref var livro = ref livros[i];
            if (!livro.Ativo) continue;

            livro.Posicao.Y += livro.Velocidade * dt;

            var area = new Rectangle((int)livro.Posicao.X, (int)livro.Posicao.Y, 12, 16);
            if (area.Intersects(captura))
            {
                pontos += livro.Raro ? 50 : 10;
                livro.Ativo = false;
            }
            else if (livro.Posicao.Y > chao)
            {
                perdidos++;
                livro.Ativo = false;
            }
        }

        return new ResultadoDaChuva(pontos, perdidos);
    }

    private void Soltar(float dificuldade)
    {
        for (var i = 0; i < livros.Length; i++)
        {
            ref var livro = ref livros[i];
            if (livro.Ativo) continue;

            livro.Raro = sorteio.NextDouble() < 0.1;
            livro.Posicao = new Vector2(sorteio.Next(8, BibliotecaEmApuros.Largura - 20), -16);
            livro.Velocidade = (livro.Raro ? 110f : 70f) * dificuldade;
            livro.Ativo = true;
            return;
        }
        // pool cheio: o livro simplesmente não nasce
    }

    public void Desenhar(SpriteBatch pincel, Recursos r)
    {
        foreach (ref readonly var livro in livros.AsSpan())
            if (livro.Ativo)
                pincel.Draw(livro.Raro ? r.LivroRaro : r.Livro, livro.Posicao, Color.White);
    }
}

Alguns recursos de C# que o curso apresentou em outros contextos encontram aqui seu uso mais natural.

ref var livro = ref livros[i], um ref local: a mesma ideia do parâmetro ref de "Métodos: Dividindo o Problema em Partes Que Conversam", agora numa variável local, que passa a ser um apelido para o elemento do arranjo. Sem o ref, var livro = livros[i] seria uma cópia da struct, e alterar livro.Posicao mudaria a cópia, não o elemento do arranjo. É o bug clássico de structs em arranjos, e o ref o elimina sem abrir mão das structs.

foreach (ref readonly var livro in livros.AsSpan()) percorre o arranjo sem copiar cada elemento e sem permitir alterá-lo. Span<T> com foreach por referência é a forma idiomática de iterar structs grandes só para leitura.

Random com semente. Com a mesma semente, a mesma sequência de livros cai nas mesmas posições e nos mesmos momentos, porque o passo fixo torna a simulação reproduzível. Isso permite testar a partida: criar uma Partida com semente 42, chamar Atualizar 600 vezes com uma entrada gravada e afirmar a pontuação final. E permite um "desafio do dia", em que todos os jogadores recebem a mesma chuva de livros.

Um pool com teto fixo tem uma consequência de design: se todos os 64 lugares estiverem ocupados, o livro seguinte simplesmente não nasce. Com a taxa de queda do jogo isso nunca acontece, mas o teto transforma um possível vazamento (um bug que nunca desativa livros) em um comportamento limitado e visível, e não num uso crescente de memória.

Medindo o que o loop aloca

A promessa de "não alocar no loop" precisa ser verificável. O .NET informa quantos bytes a thread atual alocou desde o início, e uma sobreposição de depuração mostra o valor por quadro:

#if DEBUG
private long alocadoAntes;
private long alocadoPorQuadro;
private int coletasGen0;

protected override void Update(GameTime tempo)
{
    alocadoAntes = GC.GetAllocatedBytesForCurrentThread();
    // ... o Update normal ...
}

protected override void Draw(GameTime tempo)
{
    // ... o Draw normal ...
    alocadoPorQuadro = GC.GetAllocatedBytesForCurrentThread() - alocadoAntes;
    coletasGen0 = GC.CollectionCount(0);
}
#endif

Desenhados no canto da tela (com o mesmo cuidado de só recriar a string quando o valor muda), esses dois números dizem a verdade sobre o protótipo. Durante a partida, o valor alocado por quadro fica em zero, exceto no quadro em que a pontuação muda e a string do texto é criada. O contador de coletas da geração 0 fica parado por minutos. Se uma mudança no código fizer o número subir, o problema aparece na hora, e não como um engasgo misterioso semanas depois.

Recursos e a textura de um pixel

public sealed record Recursos(
    Texture2D Bibliotecaria, Texture2D Livro, Texture2D LivroRaro,
    Texture2D Coracao, Texture2D Pixel, SpriteFont Fonte)
{
    public static Recursos Carregar(ContentManager content, GraphicsDevice dispositivo)
    {
        var pixel = new Texture2D(dispositivo, 1, 1);
        pixel.SetData([Color.White]);

        return new Recursos(
            content.Load<Texture2D>("bibliotecaria"),
            content.Load<Texture2D>("livro"),
            content.Load<Texture2D>("livro_raro"),
            content.Load<Texture2D>("coracao"),
            pixel,
            content.Load<SpriteFont>("Fonte"));
    }
}

A textura de 1×1 pixel branco é um truque antigo: desenhada com SpriteBatch.Draw num retângulo e com uma cor, ela vira qualquer retângulo sólido, como o chão, barras de vida ou a caixa de diálogo, sem precisar de uma imagem para cada. O SpriteBatch, como o nome diz, agrupa todos os desenhos entre Begin e End e os envia à GPU em poucos lotes. Desenhar mil livros custa quase o mesmo que desenhar um, desde que usem a mesma textura. Por isso jogos 2D costumam juntar todos os sprites numa única imagem, o atlas de texturas, e desenhar cada um como um retângulo de origem dentro dela.

Publicando o protótipo

dotnet publish -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true
dotnet publish -c Release -r win-x64   --self-contained -p:PublishSingleFile=true

O resultado é um executável por plataforma com o runtime e os recursos processados, que roda numa máquina sem .NET instalado. É o mesmo dotnet publish do deploy da API e do caixa: um jogo MonoGame é, afinal, um programa .NET que desenha muito rápido.

O protótipo da "Biblioteca em Apuros" funciona. Uma janela redimensionável mostra a pixel art nítida em qualquer resolução, a bibliotecária corre pelo teclado, livros caem cada vez mais rápido, os apanhados somam pontos, os perdidos custam vidas, e o loop inteiro roda sem alocar memória. Cada uma dessas coisas foi escrita à mão: o passo fixo, a entrada por comparação de estados, o teste de retângulos, o pool, a tela virtual.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega escreveu o laço da chuva como foreach (var livro in livros) { if (livro.Ativo) livro.Posicao.Y += livro.Velocidade * dt; }. O compilador recusa. Explique por quê, e por que a versão for com var livro = livros[i] compilaria mas não funcionaria.

Ver resposta

✓ Resposta: A variável de iteração de um foreach é somente leitura. Alterar um campo dela, quando ela é uma struct, é recusado pelo compilador (erro CS1654), justamente porque a alteração afetaria uma cópia e se perderia. Com var livro = livros[i] num for, o compilador aceita, porque livro é uma variável local comum, mas ela é uma cópia da struct que está no arranjo: a posição muda na cópia, que é descartada no fim da iteração, e o livro no arranjo nunca se move. O jogo mostra os livros parados no topo, sem nenhum erro. A correção é ref var livro = ref livros[i];, que torna livro um apelido para o elemento do arranjo, ou escrever diretamente livros[i].Posicao.Y += .... Esse é o motivo pelo qual structs mutáveis em coleções exigem atenção.

Exercício 2

Troque o IsFixedTimeStep = true por false e rode o protótipo num monitor de 144 Hz. O que muda na jogabilidade, e o que continua igual? Qual parte do código tornaria a diferença mais grave?

Ver resposta

✓ Resposta: Com passo variável, Update passa a ser chamado uma vez por volta, com ElapsedGameTime igual ao tempo real decorrido, perto de 6,9 ms a 144 Hz. Como todo movimento é multiplicado por dt, as velocidades em pixels por segundo continuam aproximadamente iguais, e o jogo parece o mesmo. O que muda: (1) a simulação deixa de ser reproduzível, e a mesma semente com a mesma entrada não produz mais a mesma partida em máquinas diferentes; (2) num engasgo (um quadro de 100 ms), um livro rápido percorre muitos pixels num passo só e pode atravessar a faixa de captura de 8 pixels sem que Intersects o encontre, o tunneling; (3) o intervalo entre livros, controlado por ateOProximo -= dt, pode soltar um livro atrasado. A parte que torna a diferença mais grave é a área de captura estreita: quanto menor a área e maior a velocidade, maior a chance de o livro "pular" por ela num quadro longo.

Exercício 3

Escreva um teste xUnit que prove que duas partidas com a mesma semente e a mesma sequência de entradas terminam com a mesma pontuação. Que parte do código atual impede isso, e como contorná-la?

Ver resposta

✓ Resposta: O teste cria new Partida(recursos, 42) duas vezes, chama Atualizar(1f/60, entrada) 1.800 vezes (30 s) em cada, com a mesma sequência de entradas, e compara Pontos e Vidas. O que atrapalha é o parâmetro Teclado, uma classe que lê o teclado real com Keyboard.GetState(). A correção é introduzir uma abstração da entrada, por exemplo um readonly record struct EntradaDoQuadro(float Horizontal), calculada no Game.Update a partir do teclado e passada à Partida. O teste então fornece uma lista de EntradaDoQuadro gravada ou gerada. Outro obstáculo é Recursos, que exige texturas carregadas pelo dispositivo gráfico. A partida só usa as dimensões das texturas na simulação, e essas podem ser passadas como números, separando a simulação (testável, sem GPU) do desenho. Essa separação é um dos temas de "Arquitetura de jogo e por que não alocar dentro do loop".

Exercício 4

A sobreposição de depuração mostra 96 bytes alocados por quadro durante a partida, mesmo sem a pontuação mudar. Onde você procuraria, e como confirmar o culpado?

Ver resposta

✓ Resposta: Suspeitos comuns num loop como esse: (1) uma string criada a cada quadro, como o próprio texto de depuração, se ele for formatado sempre, e não só quando o valor muda; (2) uma lambda que captura variáveis, criada dentro de Update ou Draw, por exemplo num Array.Find(livros, l => l.Posicao.Y > limite), que aloca a closure a cada chamada; (3) LINQ sobre o arranjo (livros.Where(...).Count()), que aloca enumeradores; (4) um foreach sobre uma interface (IEnumerable<Livro>) em vez do arranjo ou do Span, que aloca o enumerador; (5) boxing de uma struct, por exemplo passando um Vector2 para um método que recebe object. Para confirmar: medir GetAllocatedBytesForCurrentThread em volta de trechos menores do Update até isolar a linha, ou usar um profiler de memória (dotMemory, o profiler de alocações do Visual Studio, dotnet-trace com o provedor de alocações), que mostra a pilha de cada alocação.

Exercício 5

O jogo deve ficar mais justo para quem joga com controle. Adapte a entrada para aceitar o analógico esquerdo do gamepad, com uma zona morta. Por que a zona morta é necessária?

Ver resposta

✓ Resposta: Leia var pad = GamePad.GetState(PlayerIndex.One); e use pad.ThumbSticks.Left.X, que vai de −1 a 1. Combine com o teclado: var horizontal = teclado.Eixo(Keys.Left, Keys.Right); if (MathF.Abs(pad.ThumbSticks.Left.X) > MathF.Abs(horizontal)) horizontal = pad.ThumbSticks.Left.X;. Para a zona morta: if (MathF.Abs(horizontal) < 0.2f) horizontal = 0;, ou, melhor, uma zona morta que reescala o intervalo restante para que 0,2 vire 0 e 1 continue 1, sem salto: horizontal = MathF.Sign(x) * (MathF.Abs(x) - 0.2f) / 0.8f quando |x| > 0.2. A zona morta é necessária porque analógicos físicos nunca voltam exatamente a zero: desgaste, fabricação e temperatura deixam um resíduo de 0,02 a 0,1 com o controle solto. Sem zona morta, a bibliotecária deslizaria sozinha, devagar, para um lado. O GamePad.GetState do MonoGame já aplica uma zona morta padrão configurável, e é útil saber que ela existe e ajustá-la para o jogo.

Comentários

Mais em Linguagem C#

Input, física e colisões: fazendo o jogo parecer justo
Input, física e colisões: fazendo o jogo parecer justo

O que faz um jogo parecer justo: ações no lugar de teclas, pulo com buffer…

Do Arquivo Único à Solução: Namespaces, Projetos e Pacotes NuGet
Do Arquivo Único à Solução: Namespaces, Projetos e Pacotes NuGet

Namespaces, projetos e NuGet em C#: namespace de arquivo e global using…

Cada Valor Tem um Tipo: Variáveis, Números e Conversões em C#
Cada Valor Tem um Tipo: Variáveis, Números e Conversões em C#

Variáveis e tipos em C#: var e const, a família dos inteiros e o estouro…