Até agora, todos os dados que nossos programas utilizaram (como a posição das bombas no Campo Minado) eram perdidos quando o programa fechava. Para resolver isso, precisamos de um método de persistência de dados, ou seja, uma forma de salvar informações para que possam ser recuperadas depois. A maneira mais direta de fazer isso é através da manipulação de arquivos de texto. Neste capítulo, aprenderemos a ler e escrever em arquivos para salvar o layout do nosso jogo e o placar de vitórias e derrotas.
Quando um programa precisa ler ou escrever em um arquivo, ele estabelece um "fluxo" de comunicação. Para evitar que outro programa (ou até o próprio usuário) modifique o arquivo ao mesmo tempo e cause erros, o arquivo fica "travado" durante o uso. Por isso, o procedimento padrão é sempre:
Para que nosso programa encontre o arquivo, precisamos dizer a ele onde procurar. Existem duas formas de fazer isso: usando um caminho absoluto ou relativo.
Primeiro, crie um arquivo chamado campo.txt
e salve-o na pasta principal do seu projeto (no mesmo local
do arquivo .sln
), como mostra a figura:
É o endereço completo do arquivo, começando da raiz do seu HD (como C:\
). É específico para cada
computador. Ao usar em uma string em C#, precisamos duplicar as barras invertidas (\\
) para que a
linguagem não as confunda com caracteres de comando.
// Exemplo de caminho absoluto em uma string C# string caminho_absoluto = "C:\\Users\\Rafael\\source\\repos\\Capitulo3\\campo.txt";
É o endereço do arquivo tomando como ponto de partida o local onde o programa está sendo executado.
No caso de um projeto C# no Visual Studio, o executável fica numa pasta como bin\Debug\netcoreappX.X
.
Para chegar ao nosso arquivo na raiz do projeto, precisamos "subir" alguns níveis de pasta. Usamos ..
para subir um nível.
// Exemplo de caminho relativo para subir 4 níveis e encontrar o arquivo string caminho_relativo = "..\\..\\..\\..\\campo.txt";
Vamos alterar nosso Campo Minado para que ele carregue o mapa e o placar do arquivo campo.txt
. O arquivo
precisa ter um formato específico para que o programa consiga entendê-lo: 10 linhas para o campo (com números
separados por vírgula), uma linha em branco, uma linha para vitórias e uma para derrotas.
O código a seguir abre o arquivo, lê os dados e monta o tabuleiro do jogo.
int[,] campo = new int[10, 10]; int[,] jogo = new int[10, 10]; int qtdLinhas = campo.GetLength(0); int qtdColunas = campo.GetLength(1); bool problemaArquivo = false; string caminho_relativo = "..\\..\\..\\..\\campo.txt"; try { // 1. Cria um leitor de fluxo para o arquivo especificado StreamReader sr = new StreamReader(caminho_relativo); string linha_arq = sr.ReadLine(); // 2. Lê a primeira linha do arquivo int linha_mtz = 0; // 3. Continua lendo linha por linha, até o fim do arquivo ou até ler as 10 linhas do campo while (linha_arq != null && linha_mtz < 10) { int coluna_mtz = 0; // 4. Separa a string da linha pela vírgula (ex: "0,0,1,0...") em várias strings menores ("0", "0", "1", "0"...) foreach (var numero in linha_arq.Split(',')) { int num; // 5. Tenta converter a string para um número inteiro de forma segura if (int.TryParse(numero, out num)) { campo[linha_mtz, coluna_mtz] = num; // Armazena na matriz do campo jogo[linha_mtz, coluna_mtz] = -1; // Inicia a matriz do jogador coluna_mtz++; } } linha_arq = sr.ReadLine(); // Lê a próxima linha linha_mtz++; } // 6. Fecha o fluxo para liberar o arquivo sr.Close(); } catch (Exception e) { // 7. Se qualquer erro acontecer no bloco 'try', o código pula para cá Console.WriteLine("Ocorreu um problema na leitura do arquivo!"); problemaArquivo = true; }
try-catch
: Operações com arquivos são arriscadas (o arquivo pode não existir, estar corrompido,
etc.). O bloco try
tenta executar o código de leitura. Se qualquer erro (uma "exceção") ocorrer, o
programa não trava, ele pula para o bloco catch
e nos avisa do problema.StreamReader
: É a classe do C# responsável por ler arquivos de texto.sr.ReadLine()
: Lê uma linha inteira do arquivo e avança o "cursor" para a próxima.linha_arq.Split(',')
: Um método muito útil que quebra uma string em um vetor de strings, usando o
caractere que você definir como separador (neste caso, a vírgula).int.TryParse()
: Uma forma segura de converter texto em número. Diferente de
int.Parse()
, ele não causa um erro se a conversão falhar; em vez disso, ele retorna
false
.
sr.Close()
: Passo fundamental! Sempre feche o arquivo após o uso para que outros processos possam
acessá-lo.O restante do código do jogo agora é colocado dentro de uma condição para só ser executado se o arquivo for lido com sucesso.
if (!problemaArquivo) { bool fimJogo = false; bool vitoria = false; do { // Imprime o tabuleiro do jogador a cada rodada for (int l = 0; l < qtdLinhas; l++) { for (int c = 0; c < qtdColunas; c++) { Console.Write(string.Format("{0} ", jogo[l, c])); } Console.Write(Environment.NewLine + Environment.NewLine); } // Pede a jogada para o usuário Console.Write("Selecione uma linha [1-10]: "); int linha = Convert.ToInt32(Console.ReadLine()) - 1; Console.Write("Selecione uma coluna [1-10]: "); int coluna = Convert.ToInt32(Console.ReadLine()) - 1; // Verifica o resultado da jogada switch (campo[linha, coluna]) { case 0: jogo[linha, coluna] = 0; Console.Write("Continue tentando.\n\n"); break; case 1: jogo[linha, coluna] = 1; Console.Write("BOOOM. Você perdeu.\n\n"); fimJogo = true; break; default: // Caso encontre a bandeira (valor 2) jogo[linha, coluna] = 2; Console.Write("Parabéns. Você ganhou!\n\n"); fimJogo = true; vitoria = true; // Atualiza a flag de vitória break; } } while (!fimJogo); }
Após o fim de cada partida, vamos atualizar o placar de vitórias ou derrotas no arquivo. A estratégia será: ler todo o conteúdo do arquivo para a memória, modificar apenas a linha que queremos e, em seguida, reescrever o arquivo inteiro com o conteúdo atualizado.
Este código deve ser inserido dentro do if (!problemaArquivo)
, logo após o final do loop
do-while
do jogo.
try { // 1. Lê todas as linhas do arquivo e guarda em um vetor de strings string[] arquivo = File.ReadAllLines(caminho_relativo); // Pega as linhas de vitória e derrota (penúltima e última) string msgVitorias = arquivo[arquivo.Length - 2]; string msgDerrotas = arquivo[arquivo.Length - 1]; // 2. Abre um fluxo de ESCRITA. IMPORTANTE: Isso apaga o conteúdo atual do arquivo! StreamWriter sw = new StreamWriter(caminho_relativo); int contagem; int linha_sobrescrever; string texto; if (vitoria) { // Pega o número depois de "Vitórias:" int.TryParse(msgVitorias.Split(':')[1], out contagem); linha_sobrescrever = 11; // A contagem de vitórias está na 12ª linha (índice 11) texto = "Vitórias:"; } else { // Pega o número depois de "Derrotas:" int.TryParse(msgDerrotas.Split(':')[1], out contagem); linha_sobrescrever = 12; // A contagem de derrotas está na 13ª linha (índice 12) texto = "Derrotas:"; } contagem++; // 3. Incrementa o placar // 4. Percorre o vetor que continha as linhas originais for (int i = 0; i < arquivo.Length; i++) { // Se for a linha que queremos mudar... if (i == linha_sobrescrever) { sw.WriteLine(texto + contagem); // Escreve a nova linha com o placar atualizado } else { sw.WriteLine(arquivo[i]); // Senão, apenas reescreve a linha original } } // 5. Fecha o fluxo para salvar as alterações no arquivo sw.Close(); } catch (Exception e) { Console.WriteLine("Ocorreu um problema na escrita do arquivo!"); }
File.ReadAllLines()
: Um atalho útil para ler um arquivo inteiro e já separá-lo em um vetor de
strings.StreamWriter
: A classe para escrever em arquivos de texto. Criá-la com
new StreamWriter(caminho)
prepara o arquivo para ser completamente sobrescrito.
sw.WriteLine()
: Escreve uma string no arquivo e automaticamente adiciona uma quebra de linha no
final.Veja o seguinte conteúdo para melhor entendimento: link
A manipulação de arquivos é um recurso poderoso e relativamente simples para dar "memória" aos seus programas. Com as
classes StreamReader
e StreamWriter
, e o uso correto de tratamento de exceções com
try-catch
, você pode salvar e carregar configurações, placares, progresso de jogo e muito mais,
tornando suas aplicações mais robustas e dinâmicas.