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.pngelivro_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> </Start><End>ÿ</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
- MonoGame — Getting Started e Understanding the Game Class: https://docs.monogame.net/articles/getting_started/
- MonoGame — The Content Pipeline: https://docs.monogame.net/articles/getting_to_know/whatis/content_pipeline/
- Robert Nystrom — Game Programming Patterns, capítulos Object Pool e Update Method: https://gameprogrammingpatterns.com/object-pool.html
- Microsoft Learn — Ref locals e ref returns e Span<T>: https://learn.microsoft.com/dotnet/csharp/language-reference/statements/declarations#reference-variables
- Microsoft Learn —
GC.GetAllocatedBytesForCurrentThread: https://learn.microsoft.com/dotnet/api/system.gc.getallocatedbytesforcurrentthread - Derek Yu — Spelunky (Boss Fight Books, 2016), sobre prototipagem e ajuste de jogabilidade.
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.