$ masmorra_ascii

Parte 5 — A Forja do Código

Capítulo 31 Persistência em JSON

Capítulo 31 - Persistência em JSON

O que acontece quando o herói precisa parar no meio da masmorra? Seu corpo cansado, seus olhos piscando. Em todo roguelike clássico, desde Rogue até NetHack, save e load são essenciais. Você não pode carregar o jogo na sessão seguinte como se nada tivesse acontecido. Precisa restaurar exatamente onde parou: a posição do herói, a saúde dos seus aliados, o inventário que carrega, o mapa que explorou.

Neste capítulo você vai aplicar o async/await que aprendeu no Capítulo 30 para salvar o estado do jogo em arquivo JSON e carregá-lo depois. A combinação de assincronismo (para não congelar a masmorra enquanto salva) com serialização (para converter objetos Dart em texto) é o que torna a persistência possível.

Integração com Capítulo 30: No capítulo anterior, aprendemos como usar async/await para operações de I/O não bloqueantes. Agora aplicamos esse conhecimento num caso prático: persistência de jogo. Este é um padrão que aparecerá em todos os próximos capítulos que precisam de estado persistente.

O capítulo está dividido em duas partes. A Parte A (até a seção “Padrão toJson() / fromJson()”) cobre as fundações: como dart:io lê e escreve arquivos, como dart:convert traduz objetos em texto JSON, e como o padrão toJson()/fromJson() resolve a serialização de uma classe Dart. A Parte B (a partir de “Serializar Todo o Estado do Jogo”) aplica essas peças no caso prático do save game: agregar o estado inteiro, gerenciar múltiplos slots, fazer auto-save entre andares e restaurar a partida ao iniciar.


Parte A - Persistência Básica em Arquivos

dart:io: Lendo e Escrevendo Arquivos

A biblioteca dart:io é sua porta para o sistema de arquivos. Com ela, você lê, escreve, cria diretórios e gerencia arquivos. Essas operações são assincronas (não bloqueiam), então combinam perfeitamente com async/await do Capítulo 30.

Ler um Arquivo

// lib/persistencia/leitor.dart
import 'dart:io';

Future<String> lerArquivo(String caminho) async {
  final arquivo = File(caminho);

  if (!arquivo.existsSync()) {
    throw FileSystemException('Não encontrado: $caminho');
  }

  return arquivo.readAsString(); // ← Retorna Future (não bloqueia)
}

// Usar:
void main() async {
  try {
    final conteudo = await lerArquivo('dados.json');
    print('Lido: $conteudo');
  } catch (e) {
    print('Erro: $e');
  }
}

Saída esperada:

Lido: {"nome": "Aragorn", "hp": 42}

Escrever um Arquivo

Escrever é similar: crie um File, chame writeAsString() com await, e o arquivo é criado (ou sobrescrito se existir).

// lib/persistencia/escritor.dart
Future<void> escreverArquivo(String caminho, String conteudo) async {
  final arquivo = File(caminho);
  await arquivo.writeAsString(conteudo); // ← Escreve assincronamente
  print('Escrito: $caminho');
}

// Usar:
void main() async {
  await escreverArquivo('dados.json', '{"nome": "Herói"}');
  print('Arquivo criado!');
}

Saída esperada:

Escrito: dados.json
Arquivo criado!

Criar Diretórios

Antes de salvar, você precisa garantir que o diretório existe. Use Directory para isso. Observe que createSync() é síncrono (criação de diretório é rápido), mas para ser consistente com async/await, considere usar create() com await.

// lib/persistencia/gerenciadorDiretorios.dart
import 'dart:io';

Future<void> criarDiretorios() async {
  final dir = Directory('salves');

  if (!dir.existsSync()) {
    // ← Cria se não existir (rápido, ok síncrono)
    dir.createSync(recursive: true);
  }
}

// Ou de forma assíncrona:
Future<void> criarDiretoriosAsync() async {
  final dir = Directory('salves');

  if (!await dir.exists()) {
    await dir.create(recursive: true); // ← Forma assíncrona
  }
}

dart:convert: JSON

A biblioteca dart:convert fornece ferramentas para serializar (converter objetos em texto) e desserializar (converter texto em objetos). JSON é o formato universal: leve, legível e suportado por todas as linguagens.

Por que JSON é melhor que alternatives:

  • CSV: Difícil com dados aninhados
  • XML: Verboso, mais lento para parsear
  • Binary (protobuf, etc): Mais rápido, mas menos legível e debugável
  • JSON: Balanço perfeito: legível, estruturado, rápido

Converter Dart para JSON

// lib/serializacao/exemplo.dart
import 'dart:convert';

final dados = {
  'nome': 'Aragorn',
  'hp': 45,
  'ataque': 7,
  'inventario': ['espada', 'poção'],
};

// ← Converter para JSON string
final jsonString = jsonEncode(dados);
print(jsonString);

Saída esperada:

{"nome":"Aragorn","hp":45,"ataque":7,"inventario":["espada","poção"]}

Observe que jsonEncode() converte estruturas Dart em texto puro. Números, strings e listas são preservados. Você pode salvar jsonString num arquivo.

Converter JSON para Dart

O inverso: leia um JSON string e converta para estrutura Dart.

// lib/serializacao/decodificador.dart
import 'dart:convert';

final jsonString = '{"nome":"Aragorn","hp":45}';

final dados = jsonDecode(jsonString); // ← Converte string para Map
print(dados['nome']); // ← "Aragorn"
print(dados['hp']); // ← 45
print(dados.runtimeType); // ← Map<String, dynamic>

Saída esperada:

Aragorn
45
_InternalLinkedHashMap<String, dynamic>

Note que jsonDecode() retorna um Map<String, dynamic>: dinâmico porque pode conter qualquer tipo de valor.

Tratamento de Erros

Três erros comuns ao trabalhar com JSON:

// Erro 1: JSON malformado
try {
  jsonDecode('{"nome": "Hero"'); // ← Falta fechar
} catch (e) {
  print('Parse error: $e'); // ← FormatException
}

// Erro 2: Tipo seguro (sempre faça cast)
final dados = jsonDecode('{"numero": 42}');
final resultado = dados['numero'] as int; // ← Safe cast (valida tipo)

// Erro 3: Chave inexistente
final valor = dados['chaveQueNaoExiste']; // ← null
// ← null coalescing
final valor2 = dados['chaveQueNaoExiste'] ?? 'padrão';

Saída esperada:

Parse error: FormatException: Unexpected end of input (at character 15)
null
padrão

Sempre use try/catch ao fazer parse de JSON, pois dados corrompidos lançam FormatException.

Padrão toJson() / fromJson()

Este é o padrão de ouro em Dart para serialização: toda classe que precisa ser salva tem dois métodos:

  • toJson(): Converte a instância em Map<String, dynamic> (pronta para jsonEncode())
  • fromJson(): Factory que reconstrói a instância de um Map<String, dynamic>

É simples, poderoso e reutilizável.

Jogador: Serialização Completa

Vamos serializar um Jogador completo: atributos básicos, inventário (lista de itens), e posição. Observe como toJson() também serializa objetos aninhados (inventario, posicao), criando uma estrutura JSON profunda que jsonEncode() consegue transformar em string.

// lib/modelos/jogador.dart
class Jogador {
  String nome;
  int hpMax;
  int hpAtual;
  int ataque;
  int nivel;
  int xp;
  List<Item> inventario;
  Offset posicao;

  Jogador({
    required this.nome,
    required this.hpMax,
    this.ataque = 5,
    this.nivel = 1,
    this.xp = 0,
    this.inventario = const [],
    this.posicao = const Offset(0, 0),
  }) {
    hpAtual = hpMax;
  }

  // ← Converter para JSON (estrutura para arquivo)
  Map<String, dynamic> toJson() {
    return {
      'nome': nome,
      'hpMax': hpMax,
      'hpAtual': hpAtual,
      'ataque': ataque,
      'nivel': nivel,
      'xp': xp,
      // ← Items também serializam
      'inventario': inventario.map((i) => i.toJson()).toList(),
      'posicao': {
        'x': posicao.dx,
        'y': posicao.dy,
      },
    };
  }

  // ← Converter de JSON (reconstruir do arquivo)
  factory Jogador.fromJson(Map<String, dynamic> map) {
    return Jogador(
      nome: map['nome'] as String,
      hpMax: map['hpMax'] as int,
      ataque: map['ataque'] as int,
      nivel: map['nivel'] as int,
      xp: map['xp'] as int,
      inventario: (map['inventario'] as List)
          .map((i) => Item.fromJson(i as Map<String, dynamic>))
          .toList(), // ← Items também desserializam
      posicao: Offset(
        (map['posicao']['x'] as num).toDouble(),
        (map['posicao']['y'] as num).toDouble(),
      ),
    );
  }
}

Saída esperada (após toJson()):

{
  "nome": "Aragorn",
  "hpMax": 100,
  "hpAtual": 100,
  "ataque": 8,
  "nivel": 5,
  "xp": 1250,
  "inventario": [
    {"nome": "Espada de Elendil", "quantidade": 1},
    {"nome": "Poção de Cura", "quantidade": 3}
  ],
  "posicao": {"x": 10, "y": 15}
}

Item: Serialização Simples

Items são mais simples que jogadores. Observe que toJson() pode ser uma arrow function se for curta.

// lib/modelos/item.dart
class Item {
  String nome;
  int quantidade;

  Item({required this.nome, required this.quantidade});

  // ← Serializar (arrow function)
  Map<String, dynamic> toJson() => {
    'nome': nome,
    'quantidade': quantidade,
  };

  // ← Desserializar
  factory Item.fromJson(Map<String, dynamic> map) {
    return Item(
      nome: map['nome'] as String,
      quantidade: map['quantidade'] as int,
    );
  }
}

Saída esperada (após toJson()):

{"nome": "Poção de Cura", "quantidade": 5}

Parte B - Save Game Completo

A Parte A te deu as três pernas da mesa: ler e escrever arquivos com dart:io, traduzir entre JSON e Dart com dart:convert, e estruturar toJson()/fromJson() em uma classe simples como Pocao. Salvar uma poção em disco é educativo, mas o jogo precisa de mais que isso. Quem joga sai, volta no dia seguinte e espera achar o herói no mesmo andar, com o mesmo HP, o mesmo inventário, a mesma escada que estava a três tiles de distância.

A Parte B é onde você aplica o padrão da Parte A no caso real. Você vai agregar todo o estado do jogo numa classe EstadoJogo, criar um GerenciadorSalve que controla múltiplos slots (campanha, speedrun, new game plus), fazer auto-save entre andares para o jogador nunca perder progresso, e restaurar a sessão exatamente onde parou ao reiniciar. O resultado é o que diferencia um protótipo de uma campanha real.

Serializar Todo o Estado do Jogo

Para salvar um jogo inteiro, você precisa de uma classe que agregue todo o estado: jogador, mapa, inimigos, etc. Esta é a “foto” do jogo num momento específico.

// lib/jogo/estadoJogo.dart
class EstadoJogo {
  late Jogador jogador;
  late MapaMasmorra mapa;
  late List<Inimigo> entidades;
  int andarAtual = 0;
  DateTime ultimoSalva = DateTime.now();

  // ← Serializar tudo
  Map<String, dynamic> toJson() {
    return {
      'jogador': jogador.toJson(), // ← Jogador serializa a si mesmo
      'mapa': mapa.toJson(), // ← Mapa serializa a si mesmo
      // ← Lista de inimigos
      'entidades': entidades.map((e) => e.toJson()).toList(),
      'andarAtual': andarAtual,
      // ← DateTime como string ISO
      'ultimoSalva': ultimoSalva.toIso8601String(),
    };
  }

  // ← Desserializar tudo
  factory EstadoJogo.fromJson(Map<String, dynamic> map) {
    final estado = EstadoJogo();
    estado.jogador = Jogador.fromJson(
      map['jogador'] as Map<String, dynamic>,
    );
    estado.mapa = MapaMasmorra.fromJson(
      map['mapa'] as Map<String, dynamic>,
    );
    estado.entidades = (map['entidades'] as List)
        .map((e) => Inimigo.fromJson(e as Map<String, dynamic>))
        .toList();
    estado.andarAtual = map['andarAtual'] as int;
    estado.ultimoSalva = DateTime.parse(
      map['ultimoSalva'] as String, // ← Reconstrói DateTime de string
    );
    return estado;
  }
}

Este padrão funciona recursivamente: EstadoJogo chama toJson() de seus membros, que chamam toJson() de seus membros, e assim por diante. No final, você tem uma estrutura de JSON profunda.

MapaMasmorra: Serializar Tiles

Serializar um mapa é complexo: temos uma matriz 2D de tiles. Não podemos salvar objetos Tile diretamente; convertemos para strings (nomes dos tipos) e reconvertemos.

// lib/mundo/mapaMasmorra.dart
class MapaMasmorra {
  int largura;
  int altura;
  List<List<Tile>> tiles;

  MapaMasmorra(this.largura, this.altura)
    : tiles = List.generate(altura, (_) =>
        List.generate(largura, (_) => Tile.vazio())
      );

  // ← Serializar: converte tiles em strings
  Map<String, dynamic> toJson() {
    return {
      'largura': largura,
      'altura': altura,
      'tiles': tiles.map((linha) =>
          // ← TipoTile como string
          linha.map((tile) => tile.tipo.toString()).toList()
      ).toList(),
    };
  }

  // ← Desserializar: reconstrói tiles de strings
  factory MapaMasmorra.fromJson(Map<String, dynamic> map) {
    final largura = map['largura'] as int;
    final altura = map['altura'] as int;
    final mapa = MapaMasmorra(largura, altura);

    final tileStrings = map['tiles'] as List;
    for (int y = 0; y < altura; y++) {
      for (int x = 0; x < largura; x++) {
        final tipoStr = (tileStrings[y] as List)[x] as String;
        // ← Encontra enum pelo nome
        mapa.tiles[y][x] = Tile(tipo: TipoTile.values
          .firstWhere((t) => t.toString() == tipoStr));
      }
    }
    return mapa;
  }
}

Observe a estratégia: enums são serializados como strings, e ao desserializar, encontramos o enum novamente usando .values.firstWhere().

GerenciadorSalve: Múltiplos Slots

Um jogo típico permite vários save slots: save 1, save 2, save 3. Cada um é um arquivo separado. GerenciadorSalve centraliza toda a lógica: salvar, carregar, listar. É uma camada de abstração que o resto do jogo usa sem conhecer detalhes do disco.

// lib/persistencia/gerenciadorSalve.dart
import 'dart:convert';
import 'dart:io';

class GerenciadorSalve {
  static const String dirSalves = 'salves'; // ← Diretório de saves
  static const int numSlots = 5; // ← 5 slots disponíveis

  static Future<void> inicializar() async {
    final dir = Directory(dirSalves);
    if (!dir.existsSync()) {
      // ← Cria diretório se não existir
      dir.createSync(recursive: true);
    }
  }

  static Future<void> salvar(
    EstadoJogo estado,
    int slot,
  ) async {
    if (slot < 0 || slot >= numSlots) {
      throw ArgumentError('Slot inválido: $slot');
    }

    final arquivo = File('$dirSalves/salve_$slot.json');
    final json = jsonEncode(estado.toJson()); // ← Serializa para string

    try {
      await arquivo.writeAsString(json); // ← Escreve em disco
      print('Jogo salvo no slot $slot');
    } catch (e) {
      print('Erro ao salvar: $e');
      rethrow;
    }
  }

  static Future<EstadoJogo?> carregar(int slot) async {
    if (slot < 0 || slot >= numSlots) {
      throw ArgumentError('Slot inválido: $slot');
    }

    final arquivo = File('$dirSalves/salve_$slot.json');

    if (!arquivo.existsSync()) {
      return null; // ← Nenhum salve neste slot
    }

    try {
      final json = await arquivo.readAsString(); // ← Lê de disco
      // ← Parse JSON
      final map = jsonDecode(json) as Map<String, dynamic>;
      return EstadoJogo.fromJson(map); // ← Reconstrói estado
    } catch (e) {
      print('Erro ao carregar: $e');
      return null; // ← Arquivo corrompido
    }
  }

  // ← Listar todos os saves com timestamps
  static Future<List<DateTime?>> listarSalves() async {
    final slots = <DateTime?>[];

    for (int i = 0; i < numSlots; i++) {
      final arquivo = File('$dirSalves/salve_$i.json');

      if (arquivo.existsSync()) {
        try {
          final json = await arquivo.readAsString();
          final map = jsonDecode(json) as Map<String, dynamic>;
          final timestamp = DateTime.parse(
            map['ultimoSalva'] as String,
          );
          slots.add(timestamp);
        } catch (_) {
          slots.add(null); // ← Arquivo corrompido
        }
      } else {
        slots.add(null); // ← Vazio
      }
    }

    return slots;
  }
}

Saída esperada (após salvar):

Jogo salvo no slot 0
Jogo salvo no slot 1

Saída esperada (listarSalves()):

[2026-04-04 10:30:45, 2026-04-03 18:15:20, null, null, null]

Auto-Save Após Cada Andar

Um auto-save garante que o progresso do jogador não é perdido. No slot 0, você mantém uma “foto” automática do jogo que atualiza a cada turno. Se o jogo crasha, quando o jogador reabre, pode recuperar de onde parou.

// lib/jogo/dungeonCrawl.dart
class DungeonCrawl {
  late EstadoJogo estado;
  static const int slotAutoSalve = 0; // ← Slot dedicado para auto-save

  void executar() async {
    await GerenciadorSalve.inicializar();

    while (estado.jogador.estaVivo) {
      renderizar();
      final cmd = processarInput();
      executarComando(cmd);

    // ← Auto-salve a cada turno (importante: jogo continua responsivo)
     
      await _autoSalvar();
    }
  }

  Future<void> _autoSalvar() async {
    estado.ultimoSalva = DateTime.now();
    // ← Salva assincronamente
    await GerenciadorSalve.salvar(estado, slotAutoSalve);
  }
}

Observe que _autoSalvar() é async e await. Assim, se o disco for lento, o jogo não congela esperando—continua rodando enquanto o save acontece em background.

Saída esperada (durante jogo):

Turno 1: Herói se move
[auto-save em background]
Turno 2: Herói ataca Goblin
[auto-save em background]

Carregar Save ao Iniciar

Este é o fluxo completo: menu inicial, escolha do jogador, carregamento ou novo jogo. Integra tudo que aprendemos: async/await, persistência, JSON, tratamento de erros.

// lib/main.dart
import 'dart:io';

void main() async {
  await GerenciadorSalve.inicializar(); // ← Prepara diretório

  // ← MENU
  print('Bem-vindo ao Masmorra!');
  print('1. Novo jogo');
  print('2. Carregar salve');

  stdout.write('> ');
  final opcao = stdin.readLineSync() ?? '1';

  EstadoJogo estado;

  if (opcao == '1') {
    // ← NOVO JOGO
    estado = criarNovoJogo();
  } else {
    // ← CARREGAR JOGO
    print('\nSlots disponíveis:');
    final salves = await GerenciadorSalve.listarSalves();

    for (int i = 0; i < salves.length; i++) {
      if (salves[i] != null) {
        print('  $i. ${salves[i]}');
      } else {
        print('  $i. [Vazio]');
      }
    }

    stdout.write('Qual slot? > ');
    final slot = int.parse(stdin.readLineSync() ?? '0');

    final carregado = await GerenciadorSalve.carregar(slot);
    if (carregado == null) {
      print('Erro ao carregar. Novo jogo...');
      estado = criarNovoJogo();
    } else {
      estado = carregado;
    }
  }

  // ← INICIAR JOGO
  final game = DungeonCrawl()..estado = estado;
  game.executar();
}

Saída esperada (novo jogo):

Bem-vindo ao Masmorra!
1. Novo jogo
2. Carregar salve
> 1
[jogo começa]

Saída esperada (carregar jogo):

Bem-vindo ao Masmorra!
1. Novo jogo
2. Carregar salve
> 2

Slots disponíveis:
  0. 2026-04-04 10:30:45.123456
  1. 2026-04-03 18:15:20.654321
  2. [Vazio]
  3. [Vazio]
  4. [Vazio]
Qual slot? > 0
[jogo continua do turno salvo]

Por que não usar banco de dados?

Você poderia usar SQLite ou Firebase em vez de JSON em arquivo. Cada abordagem tem trade-offs:

JSON em arquivo (escolha neste capítulo):

  • ✓ Simples, sem dependências externas
  • ✓ Arquivo legível, fácil debugar
  • ✓ Rápido para pequenos saves (<10MB)
  • ✗ Não escala para dados gigantes
  • ✗ Sem queries sofisticadas
  • ✗ Sem índices (busca é O(n))

SQLite:

  • ✓ Rápido para muitos dados
  • ✓ Queries sofisticadas
  • ✓ Índices para busca O(1)
  • ✗ Mais complexo
  • ✗ Requer biblioteca externa

Firebase:

  • ✓ Multiplayer sincronizado
  • ✓ Backup automático na nuvem
  • ✗ Requer conexão
  • ✗ Dados compartilhados (privacidade)

Para um roguelike offline, JSON em arquivo é perfeito. Quando você precisar de multiplayer ou dados massivos, migre para banco de dados.

Desafios da Masmorra

Desafio 31.1. Seu Primeiro Await. I/O é lento—disco, rede. Dart não congela esperando. Escreva função que simula carregamento lento: Future<String> carregarHistoria() async { await Future.delayed(Duration(seconds: 1)); return 'Epopeia carregada'; }. Chame do main() com async: print(await carregarHistoria()). Note que programa continua responsivo. Dica: async + await é a base de I/O moderno.

Desafio 31.2. Serializar e Reconstruir. Escolha Item ou Arma. Implemente Map<String, dynamic> toJson(): retorna mapa com todas propriedades. E factory Item.fromJson(Map m) que reconstrói. Teste: var item = Item('Espada', 10); var map = item.toJson(); var item2 = Item.fromJson(map); expect(item2.nome, equals(item.nome));. Agora Item pode viajar como JSON. Dica: toJson/fromJson é padrão em Dart.

Desafio 31.3. Salve em Disco. JSON em memória é inútil—precisa ir pro disco. Escreva Item para arquivo JSON: (1) crie Item, (2) chame jsonEncode(item.toJson()), (3) escreva em arquivo com await File('item.json').writeAsString(json), (4) leia de volta, (5) valide que dados são iguais. Arquivo persiste após fechar programa. Dica: sempre use await em operações de arquivo.

Desafio 31.4. Múltiplos Saves. Implemente GerenciadorSalve com 3 slots: salvar(estado, slot) serializa para save_$slot.json, carregar(slot) desserializa. Trate arquivo faltando (retorna null). Teste: (1) salve estado em slot 1, (2) mude estado, (3) carregue slot 1, deve ser igual ao original. Dica: try/catch captura erros de disco.

Boss Final 31.5. Auto-Save Mágico. Você está explorando andar 3, de repente fecha o jogo. Quando reabre, está no mesmo lugar. Integre auto-save: (1) no main, crie GerenciadorSalve, (2) em cada turno/comando do jogo, await gerenciador.salvar(estadoJogo, 999) (slot auto), (3) ao iniciar, pergunte “Recuperar save anterior?”, (4) teste: jogue 10 turnos, fecha, reabre, deve estar no turno 10. Progresso é sagrado. Dica: main() deve ser async, salve após cada ação importante.

Pergaminho do Capítulo

Você aprendeu persistência completa:

  • Future é uma promessa de um valor futuro
  • async marca função como assíncrona (pode usar await)
  • await aguarda uma Future
  • dart para ler/escrever arquivos
  • dart para JSON encode/decode
  • toJson()/fromJson() para serialização
  • GerenciadorSalve gerencia múltiplos slots
  • Auto-save garante progresso não é perdido
  • Tratamento de erros para arquivos corrompidos

Um jogo sem persistência é um jogo que o jogador não pode realmente vencer: toda sessão é resetada. Com persistência, é uma campanha real que atravessa semanas.

::: vocab Vocabulário do Dia

  • Serialização - converter um objeto em texto (ou bytes) para salvar em disco ou transmitir pela rede.
  • toJson() / fromJson() - par de métodos convencional em Dart: um exporta para Map<String, dynamic>, o outro reconstrói.
  • dart:convert - biblioteca padrão para JSON: jsonEncode(obj) converte para string, jsonDecode(str) faz o inverso.
  • dart:io (revisitada) - biblioteca para arquivos: File('save.json').writeAsString(json) grava no disco.
  • Save slot - espaço de save isolado em arquivo próprio; permite várias campanhas em paralelo. :::

Próximo Capítulo

No Capítulo 32, organizaremos o projeto para escala profissional. Estrutura de pastas, imports consistentes, pubspec.yaml e analysis_options.yaml são a base de qualquer projeto Dart sério.

$ masmorra_ascii — terminal interativo
Bem-vindo. Digite help para ver os comandos. Esc para sair.
$