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

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

O que faz a Biblioteca em Apuros parecer pronta: barramentos de áudio e volume em decibéis, sons que variam, trilha que acompanha a tensão, tremor de câmera e hit-stop, menus navegáveis por controle, dois idiomas, e um save em JSON gravado de forma atômica e com migração de versões.
Linguagem C#

• • 22 min de leitura

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

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.

Comentários

Mais em Linguagem C#

EF Core: Relacionamentos e Migrations
EF Core: Relacionamentos e Migrations

Autores e pedidos como entidades relacionadas no EF Core: um para muitos com…

Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis
Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis

O que o foreach faz por baixo com IEnumerable e IEnumerator, iteradores com…

Worker Services e mensageria com RabbitMQ: trabalho fora da requisição
Worker Services e mensageria com RabbitMQ: trabalho fora da requisição

Os efeitos da venda saem da requisição: BackgroundService com Channel…