A "Biblioteca em Apuros" funciona, tem arquitetura e não gera lixo. Ainda assim, jogada em silêncio, com um menu improvisado e um recorde que some ao fechar, ela parece um protótipo. Este artigo trata do que separa as duas coisas: som, retorno visual a cada ação, uma interface completa e navegável por teclado e controle, e um save que nunca perde dados.
Som: metade da sensação
Estúdios de jogos costumam repetir que o som é metade da experiência. Um livro apanhado sem som parece que não aconteceu. Com um "tump" abafado, a mão parece ter segurado algo. Com um brilho sonoro ao apanhar um livro raro, o jogador sabe que ganhou mais antes de olhar a pontuação.
Os tocadores
O Godot tem três nós para tocar áudio:
| Nó | Como soa | Uso |
|---|---|---|
AudioStreamPlayer |
igual em qualquer lugar | música, sons de interface |
AudioStreamPlayer2D |
volume e balanço conforme a posição na tela | livro batendo no chão à esquerda soa mais à esquerda |
AudioStreamPlayer3D |
espacial 3D | jogos 3D |
Os formatos também importam. WAV (sem compressão) para efeitos curtos, que precisam começar sem atraso e são tocados muitas vezes. OGG Vorbis para música, descomprimido aos poucos enquanto toca (streaming), porque três minutos de música em WAV ocupariam dezenas de megabytes de memória. O MP3 também é suportado.
Barramentos de áudio
Todo tocador envia o som a um barramento (bus), e os barramentos formam uma mesa de mixagem, configurada no painel "Áudio" do editor:
Master
├── Musica ← trilha
└── Efeitos ← livros, pulos, traças
└── Interface ← cliques de menu
Cada barramento tem volume, mudo e uma cadeia de efeitos (reverberação, compressão, filtros). As opções de volume da tela de configurações controlam os barramentos, e não cada tocador:
public static class Mixagem
{
private static readonly StringName Musica = "Musica";
private static readonly StringName Efeitos = "Efeitos";
public static void DefinirVolume(StringName barramento, float linear)
{
var indice = AudioServer.GetBusIndex(barramento);
AudioServer.SetBusMute(indice, linear <= 0.001f);
AudioServer.SetBusVolumeDb(indice, Mathf.LinearToDb(linear));
}
}
O volume de áudio é medido em decibéis, uma escala logarítmica, e a audição humana também percebe volume de forma aproximadamente logarítmica. Um controle deslizante que variasse os decibéis linearmente teria quase todo o efeito perceptível concentrado numa pequena faixa. LinearToDb converte o valor de 0 a 1 do controle na escala certa: 0,5 vira cerca de −6 dB, metade da amplitude. O caso zero é tratado como mudo, porque o logaritmo de zero é menos infinito.
O efeito metralhadora
Quando o jogador apanha dez livros seguidos e ouve exatamente o mesmo som dez vezes, o cérebro percebe a repetição mecânica, e o som passa a irritar. Os jogos evitam isso variando levemente cada execução. No Godot 4, um recurso AudioStreamRandomizer faz isso sem código: ele recebe várias variações do mesmo som e sorteia uma a cada execução, com variação aleatória de tom (pitch) e volume dentro de limites configurados.
Quando muitos livros são apanhados no mesmo instante, o mesmo tocador precisa tocar vários sons sobrepostos. A propriedade MaxPolyphony do tocador define quantas execuções simultâneas ele aceita, sem instanciar novos nós. É o pool de "Arquitetura de jogo e por que não alocar dentro do loop", já embutido no motor.
O sistema de áudio escuta o barramento de eventos do artigo anterior:
public partial class SonsDoJogo : Node
{
[Export] public AudioStreamPlayer2D Apanhar { get; set; } = null!; // com randomizador
[Export] public AudioStreamPlayer2D ApanharRaro { get; set; } = null!;
[Export] public AudioStreamPlayer Golpe { get; set; } = null!;
[Export] public AudioStreamPlayer Batimento { get; set; } = null!; // em laço, com uma vida
public override void _Ready()
{
var eventos = GetNode<Eventos>("/root/Eventos");
eventos.LivroApanhado += (posicao, raro, _) =>
{
var tocador = raro ? ApanharRaro : Apanhar;
tocador.GlobalPosition = posicao;
tocador.Play();
};
eventos.BibliotecariaAtingida += _ => Golpe.Play();
eventos.VidasMudaram += vidas =>
{
if (vidas == 1 && !Batimento.Playing) Batimento.Play();
else if (vidas != 1) Batimento.Stop();
};
}
}
O batimento cardíaco com uma vida restante é informação, e não só atmosfera: o jogador sabe que está em perigo sem tirar os olhos dos livros para contar corações. Um bom som de jogo comunica estado.
Música que muda com a partida
A trilha acompanha a dificuldade. Duas versões da mesma música, uma calma e uma intensa, tocam em sincronia, e o volume de cada uma é trocado aos poucos conforme a dificuldade sobe:
public void AjustarIntensidade(float intensidade) // 0 = calma, 1 = frenética
{
var tween = CreateTween().SetParallel();
tween.TweenProperty(MusicaCalma, "volume_db", Mathf.LinearToDb(1 - intensidade), 2.0);
tween.TweenProperty(MusicaIntensa, "volume_db", Mathf.LinearToDb(intensidade), 2.0);
}
Um tween anima uma propriedade de um valor a outro ao longo do tempo, com curvas de suavização configuráveis. Aqui, a transição de dois segundos faz a trilha mudar sem que o jogador perceba o ponto exato da troca. Tweens reaparecem em toda parte na próxima seção.
Retorno visual: dar peso a cada ação
Designers chamam de juice, algo como "suculência", o conjunto de pequenas respostas visuais e sonoras que fazem uma ação parecer ter consequência. Nenhuma delas muda as regras. Todas mudam a sensação. A palestra Juice it or lose it, de Martin Jonasson e Petri Purho (2012), mostra o mesmo jogo simples passando de sem graça a empolgante só com esses efeitos. Na "Biblioteca em Apuros", quatro deles bastam.
Pulsar a pontuação. Quando a pontuação muda, o texto cresce um pouco e volta ao tamanho normal:
private void AnimarPontos()
{
pontos.PivotOffset = pontos.Size / 2;
var tween = CreateTween();
tween.TweenProperty(pontos, "scale", new Vector2(1.25f, 1.25f), 0.06)
.SetTrans(Tween.TransitionType.Back).SetEase(Tween.EaseType.Out);
tween.TweenProperty(pontos, "scale", Vector2.One, 0.12);
}
Tremer a câmera no golpe. Quando a traça acerta, a tela treme por um instante. A técnica popularizada por Squirrel Eiserloh na GDC de 2016 usa um valor de trauma que decai com o tempo, e o deslocamento cresce com o quadrado do trauma. Tremores fortes caem rápido para tremores fracos, o que parece mais natural que uma queda linear:
public partial class CameraDoJogo : Camera2D
{
[Export] public float DeslocamentoMaximo { get; set; } = 6f;
[Export] public float Decaimento { get; set; } = 1.8f;
private float trauma;
private readonly FastNoiseLite ruido = new() { Frequency = 4f };
private float tempo;
public void AdicionarTrauma(float quanto) => trauma = Mathf.Min(1f, trauma + quanto);
public override void _Process(double delta)
{
tempo += (float)delta;
trauma = Mathf.Max(0, trauma - Decaimento * (float)delta);
var intensidade = trauma * trauma;
Offset = new Vector2(
ruido.GetNoise2D(tempo * 60, 0) * DeslocamentoMaximo * intensidade,
ruido.GetNoise2D(0, tempo * 60) * DeslocamentoMaximo * intensidade);
}
}
O ruído coerente do FastNoiseLite (por padrão, Simplex, da mesma família do ruído de Perlin), em vez de valores aleatórios independentes a cada quadro, produz um tremor suave e contínuo, que parece físico e não um tremelique de quadro em quadro.
Congelar o tempo no golpe (hit-stop). Durante 60 milissegundos depois do golpe, o jogo inteiro congela. O instante parado dá peso ao impacto, e jogos de luta usam isso há décadas:
private async void CongelarBrevemente()
{
Engine.TimeScale = 0.05;
await ToSignal(GetTree().CreateTimer(0.06, processAlways: true, processInPhysics: false,
ignoreTimeScale: true), SceneTreeTimer.SignalName.Timeout);
Engine.TimeScale = 1.0;
}
O timer precisa ignorar a escala de tempo, senão ele próprio andaria em câmera lenta e o congelamento duraria 20 vezes mais. ToSignal converte um sinal do Godot num objeto que pode ser aguardado com await, o que permite escrever sequências temporais de forma linear. É o async/await de "async e await: Esperar Sem Bloquear", aqui usado para tempo de jogo, e não para E/S. O async void é aceitável porque o método é disparado por um evento e não tem quem aguarde o resultado.
Partículas ao apanhar um livro raro. A PoeiraDePaginas do artigo anterior dispara no ponto do contato. Para brilhos puramente visuais, um GPUParticles2D com OneShot ligado, reaproveitado a cada disparo com Restart(), faz o trabalho na placa de vídeo.
Todos esses efeitos precisam de uma opção para reduzir movimento nas configurações. Tremores de tela e flashes causam desconforto em algumas pessoas, e há jogadores com sensibilidade a movimento ou epilepsia fotossensível. A opção desliga o tremor e o congelamento, e mantém o som e as partículas pequenas.
Interface: nós de controle e contêineres
A interface do Godot usa uma família própria de nós, derivados de Control, com layout automático:
| Nó | Função |
|---|---|
VBoxContainer / HBoxContainer |
empilha os filhos na vertical ou na horizontal |
GridContainer |
grade com número fixo de colunas |
MarginContainer |
acrescenta margens |
CenterContainer |
centraliza o filho |
PanelContainer |
fundo com estilo |
Button, CheckBox, HSlider, OptionButton, LineEdit, Label |
os controles em si |
Assim como no XAML de "Interfaces no .NET", os contêineres calculam posições e tamanhos. O layout não depende de coordenadas fixas e se adapta a resoluções e a textos mais longos, como a tradução para o inglês ou para o alemão, que costuma ser bem mais longa.
A aparência vem de um recurso Theme: fontes, cores, bordas, estilos de botão em cada estado (normal, com foco, pressionado, desabilitado). Um único tema aplicado à raiz da interface estiliza todas as telas, e trocar a identidade visual é editar um arquivo, e não cem botões. É o papel que o CSS tem no site.
As telas do jogo
MenuPrincipal (Control)
└── CenterContainer
└── VBoxContainer
├── Titulo (Label)
├── Jogar (Button)
├── Recordes (Button)
├── Opcoes (Button)
└── Sair (Button)
public partial class MenuPrincipal : Control
{
[Export] public Button Jogar { get; set; } = null!;
[Export] public Button Recordes { get; set; } = null!;
[Export] public Button Opcoes { get; set; } = null!;
[Export] public Button Sair { get; set; } = null!;
public override void _Ready()
{
var fluxo = GetNode<Fluxo>("/root/Fluxo");
Jogar.Pressed += () => fluxo.IrPara(Fluxo.Tela.Jogando);
Opcoes.Pressed += () => GetNode<TelaDeOpcoes>("%Opcoes").Abrir();
Sair.Pressed += () => GetTree().Quit();
Sair.Visible = !OS.HasFeature("mobile") && !OS.HasFeature("web");
Jogar.GrabFocus();
}
}
O GrabFocus() é o detalhe mais importante do trecho. Quem joga com controle ou só com teclado não tem mouse. Sem um botão com foco ao abrir o menu, as setas e o direcional não fazem nada, e o jogador fica preso na primeira tela. Com o foco inicial, o Godot navega entre os botões pelas ações de interface (ui_up, ui_down, ui_accept), que já vêm mapeadas para teclado e controle. As propriedades FocusNeighbor* ajustam a navegação quando o layout não é uma coluna simples, e o estilo de foco do tema precisa ser visível: um botão focado que parece igual aos outros é o mesmo que não ter foco.
O botão "Sair" some em celulares e na web, onde as lojas e os navegadores esperam que o sistema feche o aplicativo. OS.HasFeature consulta as características da plataforma em tempo de execução.
A tela de pausa
O menu de pausa aparece por cima da partida, que continua visível e congelada. Para que o menu funcione com o jogo pausado, o nó dele (e seus filhos) tem ProcessMode = Always, como antecipado em "Input, física e colisões". Ao pausar, o foco vai para "Continuar". Ao despausar, o foco é liberado para que as teclas voltem ao jogo.
Opções
TelaDeOpcoes
├── Musica (HSlider 0–1)
├── Efeitos (HSlider 0–1)
├── TelaCheia (CheckBox)
├── ReduzirMovimento (CheckBox)
├── Idioma (OptionButton: Português, English)
└── Controles (Button → tela de remapeamento)
Cada controle altera o efeito na hora: mover o controle de efeitos toca um som de exemplo no novo volume, e marcar tela cheia muda a janela imediatamente com DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen). As alterações são salvas ao sair da tela, na próxima seção.
Idiomas
Os textos da interface não ficam fixos nas cenas. Cada texto é uma chave (MENU_JOGAR), e um arquivo CSV importado como tradução fornece o texto em cada idioma:
keys,pt_BR,en
MENU_JOGAR,Jogar,Play
MENU_OPCOES,Opções,Options
HUD_PONTOS,Pontos: {0},Score: {0}
FIM_RECORDE,Novo recorde!,New high score!
Os nós de controle traduzem automaticamente um Text que seja uma chave conhecida, e o código usa Tr("HUD_PONTOS") com string.Format para textos com valores. Trocar de idioma é TranslationServer.SetLocale("en"), e toda a interface muda na hora. A formatação de números segue a mesma lição de cultura de "Operadores, Texto e Conversa com o Usuário": pontos são inteiros, mas se o jogo exibisse valores decimais, "1.500" e "1,500" significariam coisas diferentes em cada idioma.
Pixel art em qualquer tela
O jogo é desenhado a 480×270, como no protótipo MonoGame. Nas configurações do projeto, o modo de ampliação viewport com escala inteira (disponível a partir do Godot 4.2) reproduz a tela virtual de "Um game loop do zero com MonoGame": o jogo é renderizado na resolução baixa e ampliado por um fator inteiro, com barras nas sobras. O filtro de textura padrão como Nearest mantém os pixels nítidos. A interface pode usar a mesma escala, para manter o estilo, ou ser desenhada na resolução nativa, com texto mais legível. Muitos jogos em pixel art escolhem a segunda opção para textos longos.
Save game
O jogo precisa lembrar três coisas entre execuções: as opções (volumes, tela cheia, idioma, controles), os recordes (os dez melhores, com nome e data) e estatísticas (partidas jogadas, livros apanhados no total), que alimentam conquistas e, no capstone, o perfil do jogador.
Opções com ConfigFile
Para preferências, o Godot oferece o ConfigFile, um arquivo no formato INI, legível e editável à mão:
public static class Opcoes
{
private const string Caminho = "user://opcoes.cfg";
public static void Salvar(float musica, float efeitos, bool telaCheia, bool reduzirMovimento, string idioma)
{
var cfg = new ConfigFile();
cfg.SetValue("audio", "musica", musica);
cfg.SetValue("audio", "efeitos", efeitos);
cfg.SetValue("video", "tela_cheia", telaCheia);
cfg.SetValue("acessibilidade", "reduzir_movimento", reduzirMovimento);
cfg.SetValue("geral", "idioma", idioma);
cfg.Save(Caminho);
}
public static void CarregarEAplicar()
{
var cfg = new ConfigFile();
if (cfg.Load(Caminho) != Error.Ok) return; // primeira execução: padrões
Mixagem.DefinirVolume("Musica", cfg.GetValue("audio", "musica", 0.8f).AsSingle());
Mixagem.DefinirVolume("Efeitos", cfg.GetValue("audio", "efeitos", 1.0f).AsSingle());
// ...
}
}
O terceiro argumento de GetValue é o valor padrão quando a chave não existe. Isso torna o arquivo tolerante a versões: uma opção nova numa atualização do jogo simplesmente não está no arquivo antigo, e o padrão é usado, sem erro nenhum. As ações remapeadas pelo jogador também podem ser salvas aqui, serializando os eventos de cada ação do InputMap.
Recordes e estatísticas com System.Text.Json
Para dados estruturados, usamos o System.Text.Json do .NET, com geração de código-fonte:
public sealed record DadosSalvos(
int Versao,
List<Recorde> Recordes,
Estatisticas Estatisticas);
public sealed record Recorde(string Nome, int Pontos, DateTimeOffset Data);
public sealed record Estatisticas(int Partidas, long LivrosApanhados, long LivrosRaros, int MaiorSequencia);
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(DadosSalvos))]
internal sealed partial class ContextoDeSave : JsonSerializerContext;
O JsonSerializerContext gera em tempo de compilação o código de serialização desses tipos, sem reflexão em tempo de execução. É a mesma técnica que as Minimal APIs usam para Native AOT, e ela importa aqui porque exportações para iOS são compiladas com AOT completo, sem geração de código dinâmica.
Gravação atômica
O exercício 5 de "Godot com C#: nós, cenas e sinais" antecipou o problema: se o jogo for encerrado durante a gravação (falta de energia, o sistema matando o processo no celular, um travamento), o arquivo fica pela metade, e o jogador perde todos os recordes, e não só o último. A escrita segura grava primeiro num arquivo temporário e depois o renomeia por cima do original. Nos sistemas de arquivos modernos, a renomeação dentro do mesmo diretório é atômica: ou o arquivo antigo continua lá inteiro, ou o novo está lá inteiro.
public static class Salvamento
{
public const int VersaoAtual = 2;
private static string Caminho => ProjectSettings.GlobalizePath("user://save.json");
public static void Salvar(DadosSalvos dados)
{
var temporario = Caminho + ".tmp";
var copia = Caminho + ".bak";
using (var fluxo = File.Create(temporario))
{
JsonSerializer.Serialize(fluxo, dados with { Versao = VersaoAtual }, ContextoDeSave.Default.DadosSalvos);
fluxo.Flush(flushToDisk: true);
}
if (File.Exists(Caminho))
File.Copy(Caminho, copia, overwrite: true); // cópia do último save bom
File.Move(temporario, Caminho, overwrite: true);
}
public static DadosSalvos Carregar()
{
foreach (var caminho in new[] { Caminho, Caminho + ".bak" })
{
try
{
if (!File.Exists(caminho)) continue;
using var doc = JsonDocument.Parse(File.ReadAllBytes(caminho));
return Migrar(doc);
}
catch (Exception e) when (e is JsonException or IOException)
{
GD.PushWarning($"Save ilegível em {caminho}: {e.Message}");
}
}
return Novo();
}
private static DadosSalvos Novo() => new(VersaoAtual, [], new Estatisticas(0, 0, 0, 0));
}
Flush(flushToDisk: true) pede ao sistema operacional que grave o conteúdo no disco antes da renomeação, e não só no cache. Sem isso, uma queda de energia logo depois poderia deixar um arquivo renomeado mas vazio. A cópia .bak é a segunda linha de defesa: se o arquivo principal estiver corrompido por qualquer outro motivo, o carregamento tenta a cópia antes de desistir. Só se os dois forem ilegíveis o jogo começa do zero, e registra um aviso.
Versões do formato
A primeira versão publicada do jogo salvava só {"recorde": 120}. A versão 2 salva a lista completa de recordes e as estatísticas. Um jogador que atualiza o jogo não pode perder seu recorde. Por isso todo save carrega um número de versão, e o carregamento migra formatos antigos:
private static DadosSalvos Migrar(JsonDocument doc)
{
var raiz = doc.RootElement;
var versao = raiz.TryGetProperty("Versao", out var v) ? v.GetInt32() : 1;
return versao switch
{
1 => new DadosSalvos(VersaoAtual,
[new Recorde("Você", raiz.GetProperty("recorde").GetInt32(), DateTimeOffset.UtcNow)],
new Estatisticas(0, 0, 0, 0)),
2 => raiz.Deserialize(ContextoDeSave.Default.DadosSalvos)!,
_ => throw new JsonException($"Save de versão {versao}, mais nova que o jogo ({VersaoAtual}).")
};
}
É o mesmo problema das migrations de banco da Fase 5 e da evolução de contratos de eventos em "Worker Services e mensageria com RabbitMQ": dados gravados hoje serão lidos por código de amanhã. A cláusula para versões mais novas cobre o caso em que o jogador volta a uma versão antiga do jogo (um downgrade, ou dois computadores com versões diferentes sincronizando o save pela nuvem). Recusar com uma mensagem clara é melhor que interpretar mal e sobrescrever dados que o jogo não entende.
Quando e onde gravar
- Quando: ao terminar uma partida (recordes e estatísticas) e ao sair da tela de opções. Nunca a cada quadro, e nunca só ao fechar o jogo, porque o fechamento pode não acontecer de forma ordenada, principalmente no celular.
- Onde: sempre em
user://, que o Godot mapeia para o diretório de dados do usuário em cada sistema. O diretório do jogo (res://) é somente leitura depois da exportação. - Nuvem: lojas como a Steam sincronizam arquivos de
user://entre computadores (Steam Cloud). A gravação atômica e o controle de versão tornam isso seguro.
O limite de um save local
Um recorde salvo num arquivo JSON local pode ser editado por qualquer um com um editor de texto. Para um recorde pessoal, tudo bem: quem trapaceia contra si mesmo só engana a si mesmo. Para um ranking público, compartilhado com todos os jogadores, um arquivo que o próprio jogador controla não vale como prova de nada. Esse é exatamente o problema que o capstone precisa resolver, e a resposta vai exigir as regras testáveis do artigo anterior rodando no servidor.
A "Biblioteca em Apuros" agora tem sons variados que comunicam o estado do jogo, uma trilha que acompanha a tensão, efeitos que dão peso a cada ação (com a opção de reduzi-los), um menu navegável por teclado e controle, pausa, opções que se aplicam na hora, dois idiomas, e um save que migra formatos antigos e sobrevive a quedas no meio da gravação.
Fontes e leituras recomendadas
- Godot Engine — Audio buses e Audio streams: https://docs.godotengine.org/en/stable/tutorials/audio/audio_buses.html
- Godot Engine — GUI: Size and anchors, Containers e Keyboard/Controller Navigation and Focus: https://docs.godotengine.org/en/stable/tutorials/ui/gui_navigation.html
- Godot Engine — Internationalizing games e Saving games: https://docs.godotengine.org/en/stable/tutorials/io/saving_games.html
- Squirrel Eiserloh — Math for Game Programmers: Juicing Your Cameras With Math (GDC 2016).
- Martin Jonasson e Petri Purho — Juice it or lose it (palestra, 2012).
- Game Accessibility Guidelines: https://gameaccessibilityguidelines.com/
Exercícios
Exercício 1
O controle deslizante de música foi ligado direto a AudioServer.SetBusVolumeDb(indice, valor * 80 - 80), uma escala linear de −80 a 0 dB. Descreva o que o jogador percebe ao arrastar o controle, e por que LinearToDb corrige.
Ver resposta
✓ Resposta: Com a escala linear em decibéis, a metade do controle dá −40 dB, que já é quase inaudível, e o trecho de 0 a cerca de 60% do controle soa praticamente mudo. Todo o ajuste perceptível fica espremido no último terço do controle: o jogador arrasta, nada acontece, e de repente o volume sobe muito num trecho curto. LinearToDb calcula 20 · log10(valor): 1,0 vira 0 dB, 0,5 vira cerca de −6 dB (metade da amplitude), 0,25 vira cerca de −12 dB, 0,1 vira −20 dB. O controle passa a representar a amplitude, e o volume percebido muda de forma mais uniforme ao longo do percurso. Muitos jogos ainda aplicam uma curva adicional (por exemplo, elevar o valor ao quadrado antes da conversão) para que o trecho baixo fique ainda mais controlável.
Exercício 2
O hit-stop foi implementado com GetTree().CreateTimer(0.06), sem os parâmetros extras, e o jogo às vezes fica em câmera lenta por mais de um segundo depois de um golpe. Explique.
Ver resposta
✓ Resposta: Por padrão, o timer da árvore respeita Engine.TimeScale. Com a escala em 0,05, os 0,06 s do timer correm a 5% da velocidade e levam 1,2 s reais para terminar, e o jogo fica em câmera lenta durante todo esse tempo. A pausa não é o problema: processAlways já vale true por padrão, e o timer continua contando com a árvore pausada. O que falta é ignoreTimeScale: true (o padrão é false), que faz o timer contar tempo real. Uma segunda proteção vale a pena: se um novo golpe chegar durante o congelamento, não iniciar outro timer que restaure a escala antes da hora, e sim prolongar o congelamento atual, guardando o instante de término num campo.
Exercício 3
Um jogador com controle relata que "o menu de opções travou": ao abri-lo, nada responde. Com mouse funciona. Encontre duas causas prováveis.
Ver resposta
✓ Resposta: (1) Nenhum controle recebe o foco ao abrir a tela. O menu principal chamou GrabFocus() no botão "Jogar", mas ao abrir as opções por cima, ninguém chamou GrabFocus() num controle da tela nova. O foco continua no botão "Opções" do menu, que agora está escondido ou atrás da tela, e o direcional não alcança nada visível. A correção é focar o primeiro controle ao abrir, por exemplo Musica.GrabFocus() em Abrir(), e devolver o foco ao botão "Opções" ao fechar. (2) A tela de opções foi aberta durante a pausa sem ProcessMode = Always, e com a árvore pausada, os controles não processam entrada. Com mouse o problema aparece do mesmo jeito se for a pausa, então a causa (1) é a mais provável. Uma terceira possibilidade: HSlider consome as setas esquerda e direita para mudar o valor, e se as propriedades de vizinhança de foco não estiverem configuradas, o jogador pode não conseguir sair do controle para cima ou para baixo.
Exercício 4
Um jogador edita o save.json e troca "Versao": 2 por "Versao": 3. O que o jogo faz ao abrir, e o que acontece com o arquivo .bak? A escolha é a certa?
Ver resposta
✓ Resposta: O carregamento lê o arquivo principal, encontra a versão 3, e Migrar lança JsonException ("mais nova que o jogo"). O catch registra o aviso e o laço tenta o .bak, que contém o save anterior à última gravação, na versão 2, e é carregado normalmente. O jogador perde, no máximo, os dados da última partida. Na próxima gravação, o arquivo principal (o editado) é copiado para .bak, e o novo save vira o principal. A partir daí o arquivo editado fica no .bak, e na gravação seguinte é sobrescrito. Para o caso real de downgrade (um computador com versão mais nova do jogo gravou pela nuvem), essa escolha também é razoável, mas há uma melhor: não gravar por cima de um save de versão mais nova e avisar o jogador para atualizar o jogo, para que a versão antiga não destrua dados que a nova sabe ler. Isso exige guardar no estado do jogo que o save foi recusado e bloquear a gravação.
Exercício 5
Liste o que o jogo deve salvar para que o jogador, ao reinstalar o jogo em outro computador com Steam Cloud, tenha a mesma experiência, e o que não deve ser salvo na nuvem.
Ver resposta
✓ Resposta: Na nuvem: recordes e estatísticas (save.json), controles remapeados, idioma, volumes e as preferências de acessibilidade (reduzir movimento), porque são escolhas da pessoa e ela espera encontrá-las em qualquer máquina. Não na nuvem: configurações que dependem do computador, como resolução, modo de tela cheia, monitor escolhido, sincronização vertical e qualidade gráfica. Um jogador que joga num notebook e num computador com monitor 4K teria a configuração de um aplicando-se ao outro, com resultados ruins. Também ficam fora os logs, caches e o .tmp e o .bak (o .bak é uma proteção local). Na prática, isso significa separar as opções em dois arquivos: preferencias.cfg, sincronizado, e video.cfg, local, e configurar a sincronização da loja só para os arquivos certos.