$ masmorra_ascii

Apêndice

Apêndice G Pergaminhos resolvidos — gabaritos dos Desafios

Apêndice G - Pergaminhos resolvidos

Gabaritos comentados dos “Desafios da Masmorra”.

Este apêndice reúne soluções de referência para os Desafios e Boss Finais espalhados pelos 37 capítulos. Use como bússola, não como caminho único. Cada problema aceita várias respostas certas; as daqui são apenas uma forma de chegar lá, escolhidas para reforçar os conceitos do capítulo correspondente.

Os pergaminhos seguem a ordem dos capítulos - dos primeiros desafios em Dart puro (Capítulo 1) até a síntese final do jogo completo (Capítulo 37). Se você tiver uma proposta de solução mais elegante, envie pelo formulário em /beta-readers.


Parte I - A Primeira Tocha

Capítulo 1 - Seu primeiro programa Dart

▸ Desafio 1.1 - Personalize o banner

Modifique o banner para incluir o seu nome como autor. Use uma nova variável autor e interpolação de string para exibi-la. Dica: use múltiplas linhas de print() para deixar o banner legível.

A ideia é praticar dois conceitos do capítulo de uma vez só: declarar variável com var e usar interpolação com cifrão. O resto é cosmético, decidir como o banner fica bonito.

// lib/main.dart
void main() {
  var nomeJogo = 'Masmorra ASCII';
  var versao = '0.1.0';
  var autor = 'Aventureiro Anônimo';

  print('');
  print('═══════════════════════════════════════');
  print('       $nomeJogo v$versao');
  print('       por $autor');
  print('═══════════════════════════════════════');
  print('');
  print('  Prepare-se para explorar');
  print('  o desconhecido.');
  print('');
  print('═══════════════════════════════════════');
  print('');
}

Quando rodar com dart lib/main.dart, o banner sai centralizado mais ou menos no mesmo lugar do original. Note que $autor é interpolação simples (uma variável). Para expressões maiores existe ${expr}, mas no banner não precisa.

Variação opcional: troque o nome hardcoded por leitura do teclado. Isso é assunto do Capítulo 2, então pode deixar para depois sem culpa.

▸ Desafio 1.2 - Explore o dart analyze

Introduza três erros diferentes no código (tipo errado, variável não usada, ponto e vírgula faltando) e veja como o dart analyze os reporta. Depois corrija todos. Observe como o analisador ajuda você a encontrar problemas sem executar o programa.

Adicione as três linhas problemáticas em qualquer ordem dentro do main() e rode dart analyze. O analisador cospe uma mensagem para cada erro, com arquivo e linha. A diferença interessante está nas categorias: erro de tipo aparece como error (impede execução), variável não usada aparece como info (apenas lembrete de limpeza), e ponto e vírgula faltando volta como error sintático.

void main() {
  int x = 'texto';        // error: 'String' não cabe em 'int'
  var naoUsada = 42;      // info: variável declarada e nunca lida
  print('teste')          // error: ; faltando no fim
}

Depois de ver as três mensagens, apague as linhas e rode dart analyze de novo. O retorno limpo (No issues found!) é o feedback positivo que você quer ver antes de cada commit.

O analisador é a primeira linha de defesa contra bugs em Dart. Com strict-casts: true no analysis_options.yaml, ele fica ainda mais rigoroso. Acostume-se a rodar dart analyze antes de cada commit; muitos bugs morrem antes mesmo do programa rodar.

▸ Desafio 1.3 - Moldura com caracteres box-drawing

Reescreva o banner usando os caracteres , , , , e para criar uma moldura completa. A moldura deve ter largura fixa de 40 caracteres. Execute e veja o resultado alinhado.

Largura fixa de 40 significa: linha de topo com , depois 38 caracteres , depois . As linhas de meio começam com , terminam com , e precisam de 38 caracteres no miolo (texto somado a espaços de preenchimento). O truque é forçar a largura com padRight(38), que adiciona espaços à direita até completar.

void main() {
  var nomeJogo = 'Masmorra ASCII';
  var versao = '0.1.0';

  print('╔══════════════════════════════════════╗');
  print('║' + ' $nomeJogo v$versao'.padRight(38) + '║');
  print('║' + ''.padRight(38) + '║');
  print('║' + '  Prepare-se para explorar'.padRight(38) + '║');
  print('║' + '  o desconhecido.'.padRight(38) + '║');
  print('╚══════════════════════════════════════╝');
}

Atenção a uma armadilha: padRight(38) não trunca. Se o texto passar de 38 caracteres, a moldura estoura. No banner inicial todos os textos cabem com folga, mas quando o jogo crescer e a moldura precisar lidar com nomes de jogador longos, vale extrair uma função utilitária que trunca ou quebra linhas. Por enquanto, simples assim resolve.

▸ Desafio 1.4 - Múltiplas linhas com quebra de linha

Em vez de usar vários print(), tente criar uma única string multilinha usando \n ou aspas triplas ('''). Execute e compare qual abordagem você acha mais legível no código.

As duas formas produzem saída idêntica. A diferença é estética. Com \n o código fica numa linha só (longa, ruim para diff e para mente que está lendo). Com aspas triplas, o código preserva a forma visual do banner no próprio arquivo, o que ajuda quando você revisa.

void main() {
  var banner = '''
═══════════════════════════════════
       Masmorra ASCII v0.1.0
═══════════════════════════════════

  Prepare-se para explorar
  o desconhecido.

═══════════════════════════════════
''';
  print(banner);
}

Aspas triplas vencem quando o conteúdo tem várias linhas; \n continua útil para mensagens curtas, de uma ou duas linhas. Pequena armadilha: a quebra de linha logo depois da abertura ''' faz parte da string. Se incomodar, coloque o primeiro caractere de conteúdo grudado em ''', sem ENTER intermediário. Aí a saída começa exatamente onde você escolheu.

⚔ Boss Final 1.5 - ASCII art de portal mágico

Crie uma arte ASCII de um portal mágico ou de uma inscrição antiga na parede da masmorra, usando apenas print(). Comece simples (5-10 linhas) e incremente. Teste caracteres especiais: , , , para efeitos visuais. O objetivo é dominar a saída no terminal e entender como texto visual funciona num roguelike.

Desafio aberto, sem solução única. A graça é tentar até gostar do resultado. Comece com um esboço grosseiro num editor de texto monoespaçado (sem fonte proporcional, senão você sofre com alinhamento). Depois transcreva linha por linha como print. Não tenha medo de espaços; eles são parte do desenho.

void main() {
  print('');
  print('         ✦ ✧ ✦');
  print('       ✧ ◆◊◆ ✧');
  print('      ✦ ╔═══╗ ✦');
  print('        ║ ◊ ║');
  print('        ║   ║');
  print('        ╚═══╝');
  print('       ✦ ✧ ✦ ✧');
  print('');
  print('   "Aqui se cruzam os mundos."');
  print('');
}

(diamante cheio) e (diamante vazio) dão peso visual quando alternados; e ficam ótimos como faíscas ou estrelas pequenas. Se o terminal mostrar quadradinhos em vez do glifo, a fonte do terminal não tem o caractere desenhado. Troque para JetBrains Mono, Fira Code ou Source Code Pro, que são as fontes recomendadas em todo o livro.

Uma sugestão de prática: depois do portal, tente desenhar uma chave, uma poção ou um pequeno dragão. Cada um exige resolver um problema diferente de alinhamento, e isso prepara o terreno para o Capítulo 6, onde a arte ASCII vira parte estruturada do jogo, com StringBuffer no lugar dos vários print().


Capítulo 2 - Conversando com o terminal

▸ Desafio 2.1 - Pergunta extra (classe do personagem)

Depois de pedir o nome, pergunte a classe do personagem (Guerreiro, Mago ou Ladrão) e inclua essa informação na saudação. Crie uma função pedirClasse() que retorna String. Valide que a entrada é uma das três opções válidas.

A função vai precisar fazer três coisas: imprimir o prompt, ler a resposta, e verificar se cabe nas três opções. Como o capítulo ainda não introduziu if, use o operador de coalescência ?? para dar fallback quando a entrada não bate com nenhuma das opções esperadas. Para garantir comparação justa, normalize a entrada com .toLowerCase().trim() antes de testar.

import 'dart:io';

String pedirClasse() {
  stdout.write('Escolha sua classe (Guerreiro, Mago, Ladrão): ');
  var entrada = (stdin.readLineSync() ?? '').trim().toLowerCase();

  var classes = ['guerreiro', 'mago', 'ladrão', 'ladrao'];
  var indice = classes.indexOf(entrada);

  // Se a entrada bateu, devolve a forma canônica com inicial maiúscula.
  // Senão, devolve a classe padrão (Aprendiz).
  if (indice == -1) return 'Aprendiz';
  if (indice == 3) return 'Ladrão';
  return entrada[0].toUpperCase() + entrada.substring(1);
}

void main() {
  stdout.write('Qual é o seu nome? ');
  var nome = stdin.readLineSync() ?? 'Aventureiro';
  var classe = pedirClasse();
  print('Bem-vindo, $nome o $classe!');
}

Aceitar ladrao sem acento é cortesia ao jogador que digita rápido. O indexOf devolve o índice se achar, ou -1 se não, e isso vira o caminho de decisão. Quando o leitor chegar ao Capítulo 3, pode trocar essa lógica por switch com pattern matching, que fica ainda mais limpo. Por enquanto, indice em lista resolve.

▸ Desafio 2.2 - Moldura dinâmica

Escreva uma função exibirEmMoldura(String texto) que recebe qualquer texto e o exibe dentro de uma moldura com bordas box-drawing. A moldura deve se ajustar dinamicamente ao tamanho do texto. Dica: use texto.length para saber o tamanho e '═' * n para repetir o caractere.

A largura interna da moldura precisa ser pelo menos o tamanho do texto somado a duas margens de espaço (uma de cada lado). A linha de topo e de baixo ficam com o caractere multiplicado por essa largura. Em Dart, o operador * aplicado a uma String repete a string n vezes, como '═' * 10 gera dez caracteres.

void exibirEmMoldura(String texto) {
  var larguraInterna = texto.length + 2;
  var linha = '═' * larguraInterna;

  print('╔$linha╗');
  print('║ $texto ║');
  print('╚$linha╝');
}

void main() {
  exibirEmMoldura('Bem-vindo, Aventureiro');
  exibirEmMoldura('Curto');
  exibirEmMoldura('Texto bem mais longo para testar o ajuste dinâmico');
}

Cada chamada produz uma moldura com tamanho exato para o conteúdo. Variação útil: aceitar um parâmetro largura opcional que força largura mínima (assim molduras consecutivas podem ficar alinhadas mesmo com conteúdos diferentes). Outra variação: aceitar uma lista de strings em vez de uma só, e cada linha vira uma linha da moldura. A lógica é a mesma, só que a largura interna é o tamanho da maior string.

▸ Desafio 2.3 - Validação de entrada robusta

Modifique pedirNome() para recusar nomes com menos de 2 caracteres ou mais de 20. Se o jogador digitar algo fora desse intervalo, mostre uma mensagem clara de erro, sugira um intervalo válido, e peça novamente em vez de usar nome padrão. Dica: use nome.length na condição e considere um loop.

Como o capítulo ainda não apresentou while, dá para resolver com recursão: a própria função se chama de novo quando a entrada é inválida. Funciona perfeitamente para validação de poucos retries (não há risco de estourar a pilha com input humano lento). Quando o leitor chegar ao Capítulo 3, pode reescrever com while (true) e break, mais idiomático em Dart.

import 'dart:io';

String pedirNome() {
  stdout.write('Qual é o seu nome (2 a 20 caracteres)? ');
  var nome = (stdin.readLineSync() ?? '').trim();

  if (nome.length < 2) {
    print('Nome curto demais. Tente de novo (mínimo 2 caracteres).');
    return pedirNome();
  }
  if (nome.length > 20) {
    print('Nome longo demais. Tente de novo (máximo 20 caracteres).');
    return pedirNome();
  }
  return nome;
}

void main() {
  var nome = pedirNome();
  print('Bem-vindo, $nome!');
}

O .trim() evita aceitar espaços em branco no início ou fim, que contam no .length e confundem o jogador. Sem ele, ” ” (um espaço) passaria como nome de 1 caractere, e ” A ” viraria nome de 5. Pequeno detalhe que economiza dor de cabeça depois. Quando esse padrão evoluir para o Capítulo 3, a recursão vira while (true) { ...; if (válido) break; }, e o leitor vê de perto a diferença de estilo.

▸ Desafio 2.4 - Múltiplas salas com navegação

Crie três funções: descreverPraca(), descreverCorredor(), descreverPorta(). Dependendo do comando que o jogador digitar na praça, chame a função correspondente. O programa ainda aceita um único comando e termina, mas a ideia de navegar entre descrições já começa a surgir. Observe como a lógica começa a ficar mais complexa.

Cada função é um pequeno bloco de prosa narrativa com print. O main lê uma palavra do jogador e usa coalescência para escolher qual função chamar. Outra forma é usar if/else if, mas como o capítulo introduz ?? e ainda não introduziu if, dá para resolver com um Map<String, Function> onde a chave é o comando e o valor é a função a chamar.

import 'dart:io';

void descreverPraca() {
  print('A Praça Central está vazia. Você ouve passos distantes.');
  print('Saídas: norte (corredor), leste (porta de madeira).');
}

void descreverCorredor() {
  print('O corredor é estreito e escuro. As paredes são de pedra úmida.');
  print('Algo brilha no chão, ao longe.');
}

void descreverPorta() {
  print('A porta de madeira tem inscrições gastas pelo tempo.');
  print('Ela range quando você se aproxima.');
}

void main() {
  descreverPraca();
  stdout.write('Para onde você vai (norte / leste / ficar)? ');
  var rumo = (stdin.readLineSync() ?? '').trim().toLowerCase();

  var rotas = {
    'norte': descreverCorredor,
    'n': descreverCorredor,
    'leste': descreverPorta,
    'l': descreverPorta,
  };

  var proxima = rotas[rumo];
  if (proxima != null) {
    proxima();
  } else {
    print('Você fica na praça, observando o silêncio.');
  }
}

O Map<String, Function> é uma forma muito limpa de despachar comandos quando você ainda não tem switch no bolso. Quando o capítulo 12 chegar e o livro apresentar o sistema de parser com enums e sealed classes, esse padrão vira a versão definitiva. Por enquanto, mapa de funções já dá leveza ao código sem cair em sequência longa de if.

⚔ Boss Final 2.5 - Diálogo com NPC (Velho Sábio)

Adicione um comando especial "falar" que inicia uma conversa com um NPC chamado Velho Sábio na Praça Central. O Velho Sábio faz uma pergunta ao jogador (por exemplo: “Qual é a sua maior virtude?” com opções “coragem”, “sabedoria”, “justiça”) e responde com uma observação diferente para cada escolha. Integre isso ao fluxo principal: depois de responder, o programa termina com uma mensagem final do Velho Sábio.

A conversa vira uma função falarComSabio() que faz uma pergunta, lê a resposta, e despacha entre três caminhos. A função final do programa imprime uma fala de despedida. Como ainda não temos switch, um Map<String, String> mapeia virtude para resposta. Para deixar o diálogo com mais vida, use travessão antes de cada fala do Sábio, que é a convenção do português para discurso direto.

import 'dart:io';

void falarComSabio() {
  print('');
  print('O Velho Sábio se vira lentamente para você.');
  print('— Qual é a sua maior virtude, aventureiro?');
  print('  (coragem, sabedoria, justiça)');
  stdout.write('> ');
  var virtude = (stdin.readLineSync() ?? '').trim().toLowerCase();

  var respostas = {
    'coragem':   'Coragem sem prudência é apenas tropeço apressado.',
    'sabedoria': 'Sabedoria pesa, mas é o único peso que cabe no inventário.',
    'justiça':   'Quem busca justiça com pressa, encontra vingança por engano.',
    'justica':   'Quem busca justiça com pressa, encontra vingança por engano.',
  };

  var resposta = respostas[virtude] ?? 'O caminho que escolhe é seu, e só seu.';
  print('— $resposta');
  print('');
  print('— Vá, e que o silêncio do corredor te ensine.');
}

void main() {
  print('Você chega à Praça Central. Um Velho Sábio descansa sob uma árvore seca.');
  stdout.write('O que você faz (falar / ir embora)? ');
  var acao = (stdin.readLineSync() ?? '').trim().toLowerCase();

  if (acao == 'falar') {
    falarComSabio();
  } else {
    print('Você ignora o Velho Sábio e segue caminho. Ele apenas sorri.');
  }
}

O travessão antes das falas é a forma clássica do português para diálogo direto, diferente do inglês que usa aspas. Funciona bem no terminal e dá um ar de literatura clássica ao jogo. Cada virtude aceita a forma com e sem acento, o que torna o jogo mais gentil com o teclado do jogador. Em capítulos futuros, o NPC vai ganhar memória entre conversas, dependência de inventário e dezenas de falas; por enquanto, três respostas e uma despedida já fazem o personagem parecer vivo.


Capítulo 3 - Decisões e repetições

▸ Desafio 3.1 - Contador de turnos

Adicione uma variável turno que começa em 1 e incrementa a cada vez que o jogador escolhe uma opção válida (não sair, não ajuda). Mostre o turno atual no prompt: "[Turno 5] > ". Isso simula a passagem de tempo na masmorra.

A regra é simples: incrementar turno só nas opções que representam ação no mundo (mover, lutar, explorar), e deixar opções de inventário ou ajuda como “neutras”. O incremento fica dentro do ramo do switch que corresponde a cada ação válida.

import 'dart:io';

void main() {
  var turno = 1;
  var rodando = true;

  while (rodando) {
    stdout.write('[Turno $turno] > ');
    var opcao = (stdin.readLineSync() ?? '').trim();

    switch (opcao) {
      case '1':
        print('Você explora os corredores escuros.');
        turno++;
        break;
      case '2':
        print('Você descansa. O tempo passa.');
        turno++;
        break;
      case 'ajuda':
      case '?':
        print('Opções: 1 explorar, 2 descansar, 0 sair.');
        // ajuda não conta turno, é meta-ação
        break;
      case '0':
        rodando = false;
        break;
      default:
        print('Não entendi. Digite "ajuda" para ver opções.');
    }
  }

  print('Você passou $turno turnos na masmorra.');
}

A ordem importa: imprima o turno antes de ler o comando, para que o jogador veja o número atual quando decide. Se incrementar antes da leitura, o display vai estar sempre um passo à frente da realidade. Quando o jogo crescer (no capítulo 7), turno vai migrar para um objeto EstadoJogo junto com HP, ouro e localização.

▸ Desafio 3.2 - Menu com mais opções (Ver mapa)

Adicione a opção “4, Ver mapa” que imprime um mapa ASCII simples fixo da masmorra. Por enquanto, o mapa pode ser um retângulo com # (paredes) e . (chão vazio). Use o mesmo padrão de switch/case para processar a opção.

A função exibirMapa() desenha o mapa com vários print ou com uma string única em aspas triplas. O switch ganha mais um case que chama essa função.

void exibirMapa() {
  print('  ╔══════════════════╗');
  print('  ║ ###########  ###║');
  print('  ║ #.........#  #.#║');
  print('  ║ #....@....####.#║');
  print('  ║ #.........#....#║');
  print('  ║ ###########....#║');
  print('  ║           ######║');
  print('  ╚══════════════════╝');
  print('  @ = você   # = parede   . = chão');
}

void main() {
  var rodando = true;
  while (rodando) {
    stdout.write('Opção (1-4, 0 sair): ');
    var opcao = (stdin.readLineSync() ?? '').trim();
    switch (opcao) {
      case '1': print('Explorar...');     break;
      case '2': print('Descansar...');    break;
      case '3': print('Ver inventário.'); break;
      case '4': exibirMapa();             break;
      case '0': rodando = false;          break;
      default:  print('Não entendi.');
    }
  }
}

O @ no centro do mapa marca a posição do jogador. Por enquanto é fixo; no Capítulo 15, o mapa passa a ser uma grade List<List<Tile>> e a posição do @ muda com WASD. Este desafio é o ensaio visual: o leitor já tem o vocabulário de tiles na cabeça quando chegar a hora.

▸ Desafio 3.3 - HP que diminui (simulando perigo)

Declare var hp = 100; no início. Cada vez que o jogador escolher explorar, reduza o HP em um valor fixo (por exemplo, 10-20 pontos de dano simulado). Mostre o HP atualizado no status. Se HP chegar a 0 ou menos, encerre o jogo com uma mensagem de derrota.

Uma constante de dano fixo já basta para sentir o conceito. Depois de aplicar o dano, verifica se hp <= 0; nesse caso, sai do loop com uma mensagem de derrota.

import 'dart:io';

void main() {
  var hp = 100;
  var rodando = true;

  while (rodando && hp > 0) {
    stdout.write('[HP $hp] > ');
    var opcao = (stdin.readLineSync() ?? '').trim();

    switch (opcao) {
      case '1':
        print('Você explora. Algo te machuca no escuro.');
        hp -= 15;
        break;
      case '2':
        print('Você descansa por um turno.');
        // sem dano, mas também sem cura aqui
        break;
      case '0':
        rodando = false;
        break;
      default:
        print('Opção inválida.');
    }

    if (hp <= 0) {
      print('');
      print('O escuro venceu. HP chegou a 0.');
      print('Você morre na masmorra após resistir até onde pôde.');
    }
  }
}

Note o while (rodando && hp > 0): a condição composta garante que tanto a saída voluntária quanto a morte interrompem o loop. Variação interessante: trocar o dano fixo por aleatório com Random().nextInt(15) + 5, que dá 5 a 19 de dano. Isso é tema do Capítulo 17, quando entram as sementes e a aleatoriedade controlada.

▸ Desafio 3.4 - Confirmação ao sair (dupla verificação)

Quando o jogador escolher sair (opção 0), pergunte “Tem certeza (s/n)?” de forma clara. Se digitar s, sim, ou y (yes), saia de verdade. Caso contrário, volte ao menu.

Use uma função auxiliar confirmar(String pergunta) que devolve bool. Ela aceita várias formas afirmativas (s, sim, y, yes) e considera qualquer outra coisa como “não”. Isso evita despedidas acidentais por uma tecla pressionada por engano.

import 'dart:io';

bool confirmar(String pergunta) {
  stdout.write('$pergunta (s/n): ');
  var resposta = (stdin.readLineSync() ?? '').trim().toLowerCase();
  return resposta == 's' || resposta == 'sim' ||
         resposta == 'y' || resposta == 'yes';
}

void main() {
  var rodando = true;
  while (rodando) {
    stdout.write('> ');
    var opcao = (stdin.readLineSync() ?? '').trim();
    if (opcao == '0') {
      if (confirmar('Tem certeza que quer sair?')) {
        rodando = false;
      } else {
        print('Continuando...');
      }
    } else {
      print('Opção: $opcao');
    }
  }
  print('Até a próxima descida.');
}

A função confirmar é pequena, mas vai ser reusada em vários momentos do jogo: confirmar gastar todo o ouro numa compra, confirmar usar o último item de cura, confirmar abandonar a sala do boss. Quando o capítulo 23 introduzir a loja, confirmar reaparece sem mudar uma linha.

⚔ Boss Final 3.5 - Painel de estatísticas finais

Ao final do jogo (sair confirmado ou morte), exiba um painel com nome do herói, total de turnos, HP final, dano sofrido (100 − HP final) e nota final (S, A, B, C) baseada em turnos/HP. Use ternários para determinar a nota e box-drawing para tornar o painel visual.

A nota é o coração do desafio: aninhar ternários para mapear turnos/HP em letras. Como o objetivo é mostrar ternários, mantenha uma única expressão encadeada em vez de várias if/else if.

String avaliarNota(int turnos, int hp) {
  return (turnos >= 30 && hp >= 60) ? 'S'
       : (turnos >= 20 && hp >= 40) ? 'A'
       : (turnos >= 10)             ? 'B'
       :                              'C';
}

void exibirPainel(String nome, int turnos, int hp) {
  var dano = 100 - hp;
  var nota = avaliarNota(turnos, hp);

  print('╔════════════════════════════════╗');
  print('║       FIM DA JORNADA           ║');
  print('╠════════════════════════════════╣');
  print('║ Herói:     ${nome.padRight(20)}║');
  print('║ Turnos:    ${turnos.toString().padRight(20)}║');
  print('║ HP final:  ${hp.toString().padRight(20)}║');
  print('║ Dano:      ${dano.toString().padRight(20)}║');
  print('║ Nota:      ${nota.padRight(20)}║');
  print('╚════════════════════════════════╝');
}

void main() {
  // exemplo de uso ao terminar o loop principal:
  exibirPainel('Aldric', 24, 55);
}

padRight(20) força largura fixa nas células, mantendo a borda direita alinhada independente do conteúdo. A nota encadeada pode ser lida de cima para baixo: a primeira condição verdadeira ganha. É legível, mas ternários aninhados perdem clareza rápido quando passam de três níveis; nesse ponto, vale a pena migrar para switch ou if/else if explícito. Quando o capítulo 25 introduzir XP e níveis, esse painel ganha mais campos (XP acumulado, nível, capítulos visitados) e provavelmente vira uma classe Resultado com método imprimir().


Capítulo 4 - Null safety, o escudo contra crashes

Desafio 4.1 - Validação de nome robusta

Reescreva pedirNome() para recusar nomes com menos de 2 caracteres ou mais de 20. Se inválido, mostre exatamente o motivo (“Muito curto”, “Muito longo”) e peça novamente em vez de usar um padrão. Use promoção de tipo dentro de um if (entrada != null) para garantir segurança.

Solução de referência. A função vira um laço que insiste até receber algo aceitável. A peça-chave é o if (entrada != null): dentro desse bloco, o Dart promove o tipo de String? para String, e aí entrada.trim() e entrada.length ficam disponíveis sem ! ou ?.. As mensagens de erro precisam ser específicas o suficiente para o jogador entender o que está acontecendo: dizer só “inválido” frustra; “muito curto, precisa de 2 letras” educa.

import 'dart:io';

String pedirNome() {
  while (true) {
    stdout.write('Qual seu nome, aventureiro? ');
    var entrada = stdin.readLineSync();

    if (entrada != null) {
      var nome = entrada.trim();

      if (nome.length < 2) {
        print('Muito curto. Use pelo menos 2 letras.');
      } else if (nome.length > 20) {
        print('Muito longo. Use no máximo 20 letras.');
      } else {
        return nome;
      }
    } else {
      print('Não captei nada. Tente de novo.');
    }
  }
}

A armadilha clássica é esquecer o trim() e deixar passar espaços em branco como nome válido. Outra é validar entrada.length direto sobre o String? sem promoção de tipo, o que o Dart recusa em modo null-safe. Em variações futuras, pode-se rejeitar caracteres especiais com RegExp(r'^[a-zA-ZáéíóúÁÉÍÓÚãõâêôÃÕÂÊÔç ]+$') para manter o tom medieval.

Desafio 4.2 - Menu com confirmação bilateral

Crie uma função confirmar(String mensagem) -> bool que mostra a mensagem, aceita s/sim/y/yes para verdadeiro e n/não/no para falso. Se o jogador digitar algo inválido, repita a pergunta. Use ?? para proteger readLineSync().

Solução de referência. O segredo é normalizar a entrada antes de comparar: toLowerCase().trim() mata variações como “Sim”, ” sim ”, “SIM ”. O operador ?? cobre o caso em que readLineSync() devolve null (entrada vazia em alguns terminais), tratando como “tente de novo” em vez de derrubar o programa. Devolver bool (não int nem String) deixa a função encaixar direto em qualquer if, mantendo o código de quem chama limpo.

import 'dart:io';

bool confirmar(String mensagem) {
  const sim = {'s', 'sim', 'y', 'yes'};
  const nao = {'n', 'não', 'nao', 'no'};

  while (true) {
    stdout.write('$mensagem (s/n) ');
    var resposta = (stdin.readLineSync() ?? '').toLowerCase().trim();

    if (sim.contains(resposta)) return true;
    if (nao.contains(resposta)) return false;

    print('Responda com s ou n.');
  }
}

void main() {
  if (confirmar('Deseja entrar na caverna?')) {
    print('Você avança bravamente.');
  } else {
    print('Você recua, prudente.');
  }
}

Usar Set ({...}) em vez de List para sim e nao torna a busca por contains em tempo constante - irrelevante para 4 elementos, mas é o tipo certo do ponto de vista semântico (sem duplicatas, sem ordem). Uma armadilha sutil: se você usar == direto com cada variação, vai escrever uma cadeia gigantesca de ||; o Set.contains mantém a função respirável quando aceitar “claro”, “ok”, “talvez não” entrar na lista.

Desafio 4.3 - Interpretação de comandos em três níveis

Implemente um parser que reconheça três formas do mesmo comando: numeração (1), palavra completa (explorar) e abreviação (e). Use int.tryParse() para tentar número primeiro, depois ?? para tentar palavra, depois ?? para tentar abreviação. Demonstre com explorar/e/1.

Solução de referência. Pense no parser como uma cadeia de tentativas: primeiro testa se a entrada é um número (índice no menu), se não for, testa se bate com a palavra cheia, e por último com a abreviação. O ?? encaixa essas tentativas naturalmente porque cada etapa devolve String? - se acertou, devolve o comando canônico; se não, devolve null para a próxima tentativa pegar.

const comandos = ['explorar', 'inventario', 'descansar', 'sair'];
const abreviacoes = {'e': 'explorar', 'i': 'inventario', 'd': 'descansar', 's': 'sair'};

String? porNumero(String entrada) {
  var idx = int.tryParse(entrada);
  if (idx == null) return null;
  if (idx < 1 || idx > comandos.length) return null;
  return comandos[idx - 1];
}

String? porPalavra(String entrada) {
  return comandos.contains(entrada) ? entrada : null;
}

String? porAbreviacao(String entrada) {
  return abreviacoes[entrada];
}

String? interpretar(String entrada) {
  var limpa = entrada.toLowerCase().trim();
  return porNumero(limpa) ?? porPalavra(limpa) ?? porAbreviacao(limpa);
}

void main() {
  for (var teste in ['explorar', 'e', '1', 'EXPLORAR ', 'xyz']) {
    print('"$teste" -> ${interpretar(teste) ?? "desconhecido"}');
  }
}

Saída esperada: explorar, explorar, explorar, explorar, desconhecido. O ganho dessa estrutura é que cada estratégia mora numa função própria; adicionar uma quarta (sinônimos, por exemplo) é encaixar mais um ?? no final. A armadilha é deixar int.tryParse retornar índice sem checar limites - acessar comandos[99] derruba o jogo com RangeError. Outra é esquecer o toLowerCase, e aí “Explorar” não casa com “explorar”.

Desafio 4.4 - Função parametrizada pedirTexto

Escreva String pedirTexto(String prompt, {int minLength = 1, int maxLength = 50}) com parâmetros nomeados e valores padrão. A função repete até receber um texto com tamanho válido. Use texto.length e lance exceção (ou retorne padrão) se sair do intervalo.

Solução de referência. Parâmetros nomeados com {} deixam quem chama escolher o que customizar e o que aceitar com padrão. Para pedirTexto('Nome:'), os limites são 1 e 50; para pedirTexto('Bio:', maxLength: 200), só o teto muda. Validar logo na entrada (em vez de devolver e validar fora) concentra a lógica num lugar só - o resto do código pode confiar que o retorno está dentro do contrato.

import 'dart:io';

String pedirTexto(
  String prompt, {
  int minLength = 1,
  int maxLength = 50,
}) {
  while (true) {
    stdout.write('$prompt ');
    var entrada = (stdin.readLineSync() ?? '').trim();

    if (entrada.length < minLength) {
      print('Curto demais (mínimo $minLength caracteres).');
      continue;
    }
    if (entrada.length > maxLength) {
      print('Longo demais (máximo $maxLength caracteres).');
      continue;
    }
    return entrada;
  }
}

void main() {
  var titulo = pedirTexto('Título do diário:', minLength: 3, maxLength: 30);
  var corpo = pedirTexto('Conteúdo:', maxLength: 200);
  print('"$titulo" salvo com ${corpo.length} caracteres.');
}

A escolha entre lançar exceção e repetir o prompt depende do contexto: aqui, como é entrada interativa, repetir é mais amigável - o jogador erra, lê o motivo, tenta de novo. Exceção faz sentido em camadas de domínio (parsing de save, leitura de config), onde quem chama precisa decidir o que fazer com erro. Armadilha comum: trocar >= por > (ou vice-versa) sem perceber. Se minLength: 3, a comparação correta é length < 3 (rejeita 0, 1, 2; aceita 3+).

Boss Final 4.5 - Cadeia de null safety (Sala inicial)

Crie um mapa representando três salas: salaPraca, salaCorredo, salaTesouraria, cada uma como String?. Algumas salas podem ser null (não existem). Implemente um getter salaAtual() -> String que usa encadeamento ?? para sempre garantir que o jogador está em uma sala válida, caindo para “Praça Central” se tudo mais for null. Demonstre que o encadeamento funciona mesmo com múltiplos níveis de null.

Solução de referência. O ?? brilha quando você tem várias fontes alternativas para um mesmo valor e quer “a primeira que não for nula”. Pense num jogo onde o jogador pode estar carregando uma sala de save, ou uma sala de teleporte, ou - no pior caso - voltou para a praça inicial porque tudo o mais fracassou. O encadeamento a ?? b ?? c ?? padrão lê de cima para baixo: pega o primeiro não nulo, ignora o resto.

class Mundo {
  String? salaPraca;
  String? salaCorredor;
  String? salaTesouraria;

  String salaAtual() {
    return salaPraca
        ?? salaCorredor
        ?? salaTesouraria
        ?? 'Praça Central';
  }
}

void main() {
  var mundo = Mundo();

  // tudo null -> fallback
  print(mundo.salaAtual());  // Praça Central

  // só a tesouraria existe
  mundo.salaTesouraria = 'Tesouraria do Rei';
  print(mundo.salaAtual());  // Tesouraria do Rei

  // corredor e tesouraria existem; corredor vem primeiro
  mundo.salaCorredor = 'Corredor das Tochas';
  print(mundo.salaAtual());  // Corredor das Tochas

  // tudo preenchido; praça ganha porque está primeiro na cadeia
  mundo.salaPraca = 'Praça do Mercado';
  print(mundo.salaAtual());  // Praça do Mercado
}

O detalhe importante é que o último elo da cadeia precisa ser não-anulável ('Praça Central' literal) para o tipo de retorno ser String, não String?. Se você escrever salaPraca ?? salaCorredor ?? salaTesouraria sem o literal final, Dart infere String? e quem chama precisa lidar com null de novo - perdendo justamente a garantia que esse padrão promete. Em variações com class Sala, a mesma técnica funciona devolvendo a primeira Sala não-nula; a regra é: a cadeia só vale se o último elo for sólido.


Capítulo 5 - Coleções, o inventário do herói

Desafio 5.1 - Expandir o mundo (Mais salas)

Adicione pelo menos duas salas novas ao mapa: por exemplo, “Câmara do Tesouro” e “Biblioteca Antiga”. Conecte-as ao mundo existente com saídas apropriadas e descrições atmosféricas. Teste navegando até elas e verificando se as saídas funcionam nos dois sentidos.

Solução de referência. Adicionar salas novas é o exercício mais didático do capítulo, porque mostra que o “mundo” é só um Map<String, Map> - dados, não código. A regra de ouro é manter saídas simétricas: se a Praça leva ao norte para a Câmara, a Câmara precisa levar ao sul para a Praça. Quebrar essa simetria deixa o jogador “preso” e cria bugs difíceis de rastrear.

final mundo = <String, Map<String, dynamic>>{
  'praca': {
    'nome': 'Praça Central',
    'descricao': 'Uma praça empoeirada com uma fonte seca no centro.',
    'saidas': {'norte': 'camara', 'leste': 'biblioteca'},
    'itens': <String>['Moeda de Ouro'],
  },
  'camara': {
    'nome': 'Câmara do Tesouro',
    'descricao': 'Ouro empilhado até o teto, mas algo te observa nas sombras.',
    'saidas': {'sul': 'praca'},
    'itens': <String>['Gema Rubra', 'Coroa de Prata'],
  },
  'biblioteca': {
    'nome': 'Biblioteca Antiga',
    'descricao': 'Estantes inclinadas, cheiro de pergaminho úmido. Um livro brilha.',
    'saidas': {'oeste': 'praca'},
    'itens': <String>['Tomo Arcano'],
  },
};

void descreverSala(String chave) {
  var sala = mundo[chave]!;
  print('== ${sala['nome']} ==');
  print(sala['descricao']);
  var saidas = (sala['saidas'] as Map).keys.join(', ');
  print('Saídas: $saidas');
}

Para testar simetria sem rodar o jogo inteiro, escreva um pequeno validador: para cada sala, para cada saída, confira que a sala destino tem alguma saída de volta para a origem. Esse hábito vira reflexo quando o mapa cresce para 20+ salas. Outra variação útil é dar a cada sala um campo 'visitada': false que fica true na primeira entrada - útil para conquistas e mapeamento progressivo (cap. 9 vai usar isso).

Desafio 5.2 - Largar itens

Implemente o comando "largar <item>" que remove um item do inventário e o coloca na sala atual (adicionando à lista de itens da sala). Valide que o jogador realmente possui o item antes de largá-lo. Dica: é exatamente o inverso de pegarItem.

Solução de referência. A simetria entre pegar e largar é o que torna o mundo “real” - o item não some, só muda de container. O cuidado é validar três coisas antes de mexer: o jogador possui o item, o argumento não está vazio, e a sala existe. Usar removeWhere ou remove em vez de um índice manual evita bugs quando há itens repetidos no inventário.

bool largarItem(String item, String salaAtual, List<String> inventario) {
  if (item.trim().isEmpty) {
    print('Largar o quê? Especifique um item.');
    return false;
  }

  // .firstWhere com orElse retorna '' se não achar
  var encontrado = inventario.firstWhere(
    (i) => i.toLowerCase() == item.toLowerCase(),
    orElse: () => '',
  );

  if (encontrado.isEmpty) {
    print('Você não tem "$item" no inventário.');
    return false;
  }

  inventario.remove(encontrado);
  (mundo[salaAtual]!['itens'] as List<String>).add(encontrado);
  print('Você largou $encontrado no chão.');
  return true;
}

A pegadinha aqui é o as List<String> para o cast do conteúdo dinâmico do Map. Sem ele, o Dart trata o valor como dynamic e perde a verificação de tipo no .add. A variação mais comum é dar peso aos itens: largar passa a ser estratégico quando você precisa abrir espaço para algo mais valioso. Esse mecanismo aparece de novo no cap. 10, quando o inventário virar class Inventario com método largar.

Desafio 5.3 - Limite de inventário com feedback

Adicione um limite de 5 itens máximo no inventário. Se estiver cheio, mostre uma mensagem clara: “Sua mochila está cheia! Você tem 5/5 itens. Largue algo antes de pegar novo item.” Use .length para verificar.

Solução de referência. Limite + feedback claro é design de UX disfarçado de código. O bug clássico é deixar o jogador “pegar” mesmo cheio e silenciosamente ignorar - ele só percebe no inventário. A solução boa rejeita antes, mostra o estado atual (5/5) e explica como sair da situação. Constante no topo do arquivo deixa o número fácil de ajustar mais tarde.

const capacidadeMaxima = 5;

bool pegarItem(String item, String salaAtual, List<String> inventario) {
  if (inventario.length >= capacidadeMaxima) {
    print('Sua mochila está cheia! '
        'Você tem ${inventario.length}/$capacidadeMaxima itens.');
    print('Largue algo antes de pegar novo item.');
    return false;
  }

  var itensSala = mundo[salaAtual]!['itens'] as List<String>;
  if (!itensSala.contains(item)) {
    print('Não há "$item" aqui.');
    return false;
  }

  itensSala.remove(item);
  inventario.add(item);
  print('Você pegou $item. '
      '(${inventario.length}/$capacidadeMaxima itens)');
  return true;
}

Note o feedback positivo no fim: depois de pegar com sucesso, o jogador vê o novo total. Isso evita a sensação de “será que pegou mesmo?” que aparece quando a mensagem é genérica. Uma variação interessante é capacidade variável por tipo de mochila - “saco simples: 3, mochila de couro: 6, mochila encantada: 10”. Aí a constante vira um campo do jogador, e o capítulo 12 (classes) tem onde encaixar isso.

Desafio 5.4 - Sala de tesouro (Múltiplos itens)

Adicione uma sala especial “Câmara do Tesouro” com 5 itens valiosos (Moeda de Ouro, Anel de Prata, Diamante, Corrente, Gema). Implemente o comando "pegar tudo" que pega todos os itens da sala de uma vez, respeitando o limite do inventário. Se a mochila ficar cheia no meio, mostre quantos pôde pegar.

Solução de referência. “Pegar tudo” parece simples mas tem três comportamentos para acertar: respeitar o limite, dar feedback parcial (peguei 3 dos 5, deixei 2), e remover só o que entrou no inventário. Um for com break ao bater no limite cobre os três. Iterar sobre uma cópia da lista evita o erro clássico de mutar a coleção enquanto ela é percorrida (ConcurrentModificationError).

void pegarTudo(String salaAtual, List<String> inventario) {
  var itensSala = mundo[salaAtual]!['itens'] as List<String>;

  if (itensSala.isEmpty) {
    print('Não há nada para pegar aqui.');
    return;
  }

  var pegos = <String>[];
  var deixados = <String>[];

  // .toList() cria cópia para iterar sem corromper a original
  for (var item in itensSala.toList()) {
    if (inventario.length >= capacidadeMaxima) {
      deixados.add(item);
    } else {
      inventario.add(item);
      itensSala.remove(item);
      pegos.add(item);
    }
  }

  if (pegos.isNotEmpty) {
    print('Você pegou: ${pegos.join(", ")}');
  }
  if (deixados.isNotEmpty) {
    print('Mochila cheia. Deixou no chão: ${deixados.join(", ")}');
  }
  print('Inventário: ${inventario.length}/$capacidadeMaxima');
}

A montagem da sala em si reutiliza o padrão do desafio 5.1:

mundo['tesouro'] = {
  'nome': 'Câmara do Tesouro',
  'descricao': 'Cinco objetos brilham sobre o pedestal central.',
  'saidas': {'oeste': 'camara'},
  'itens': <String>[
    'Moeda de Ouro',
    'Anel de Prata',
    'Diamante',
    'Corrente',
    'Gema',
  ],
};

A armadilha sutil é iterar direto sobre itensSala em vez de itensSala.toList(). Como o .remove muda a lista durante a iteração, o Dart lança ConcurrentModificationError. A cópia rasa via toList() é leve e resolve. Em variações, dá para ordenar itensSala por valor antes de pegar (pega os mais caros primeiro) usando uma Map<String, int> de preços.

Boss Final 5.5 - Visualizar o mundo (Mapa de adjacência)

Crie uma função exibirMapaMundi() que imprime um diagrama ASCII mostrando todas as salas conectadas. Por exemplo, usando um formato de árvore ou de grafo simples. Use nomes de salas e setas para mostrar as conexões (Praça →[norte] Corredor, etc). Isso ajuda o jogador a visualizar a topologia do mundo.

Solução de referência. Visualizar um grafo em ASCII sem libs externas é trabalho de carpintaria: você não tenta desenhar bonito, você tenta desenhar legível. A abordagem mais simples e útil é uma lista de adjacência - cada sala vira uma linha com suas saídas. Mais elegante que tentar posicionar caixas, e mais escalável para 20+ salas. Para os jogadores que querem mapa “real”, o cap. 9 vai introduzir grids 2D.

void exibirMapaMundi() {
  print('╔═══════════════════════════════════════════════════╗');
  print('║              MAPA DO MUNDO CONHECIDO              ║');
  print('╠═══════════════════════════════════════════════════╣');

  // ordena por nome para saída estável
  var chaves = mundo.keys.toList()..sort();

  for (var chave in chaves) {
    var sala = mundo[chave]!;
    var nome = sala['nome'] as String;
    print('║ ◆ $nome');

    var saidas = sala['saidas'] as Map<String, dynamic>;
    if (saidas.isEmpty) {
      print('║    └─ (sem saídas)');
    } else {
      var entradas = saidas.entries.toList();
      for (var i = 0; i < entradas.length; i++) {
        var ultima = i == entradas.length - 1;
        var conector = ultima ? '└─' : '├─';
        var destino = mundo[entradas[i].value]!['nome'] as String;
        print('║    $conector ${entradas[i].key}$destino');
      }
    }
    print('║');
  }

  print('╚═══════════════════════════════════════════════════╝');
}

Saída típica:

║ ◆ Biblioteca Antiga
║    └─ oeste → Praça Central

║ ◆ Câmara do Tesouro
║    └─ sul → Praça Central

║ ◆ Praça Central
║    ├─ norte → Câmara do Tesouro
║    └─ leste → Biblioteca Antiga

Os caracteres ├─ e └─ são tree-drawing Unicode (mesma família do tree no terminal). O detalhe i == entradas.length - 1 escolhe entre “branch intermediário” e “branch final”, dando o visual de árvore. Em variações, marque a sala atual com ou [VOCÊ] para o jogador se orientar; descobertas progressivas (só mostrar salas visitadas) viram um filtro em cima da lista de chaves.


Capítulo 6 - Arte ASCII e StringBuffer

Desafio 6.1 - Moldura com título e rodapé

Modifique a função moldura() para aceitar um parâmetro opcional rodape. Se fornecido, adicione uma linha separadora (com ) entre o conteúdo e o rodapé, depois exiba o rodapé com alinhamento. Por exemplo: uma caixa de inventário com “Mochila Vazia” como rodapé.

Solução de referência. A moldura ganha uma área inferior opcional separada por linha horizontal. A peça importante é calcular a largura uma vez e reaproveitar: o topo, o rodapé e o separador interno usam a mesma largura, então alinhamento sai de graça. Padronizar padRight no conteúdo evita borda direita “dançando” quando linhas têm tamanhos diferentes.

void moldura(List<String> linhas, {String? rodape, int largura = 40}) {
  var topo = '╔${'═' * largura}╗';
  var meio = '╠${'═' * largura}╣';
  var base = '╚${'═' * largura}╝';

  print(topo);
  for (var linha in linhas) {
    print('║ ${linha.padRight(largura - 1)}║');
  }

  if (rodape != null) {
    print(meio);
    print('║ ${rodape.padRight(largura - 1)}║');
  }
  print(base);
}

void main() {
  moldura(
    ['Espada Curta', 'Tocha', 'Poção Pequena'],
    rodape: 'Itens: 3/5',
  );

  // mochila vazia
  moldura(
    [],
    rodape: 'Mochila Vazia',
  );
}

A armadilha clássica é misturar runes.length com string.length quando o conteúdo tem emojis ou caracteres compostos. Para texto comum em pt-BR (acentos), o length em String conta UTF-16 code units, o que coincide com “caracteres” na maioria dos casos - mas se um dia entrar 🗡️, a contagem quebra. Para o livro inteiro, ASCII e box-drawing simples são suficientes; emojis decorativos viram um caso especial só no cap. 23 (build de release).

Desafio 6.2 - Barra de XP customizada

Crie uma função barraXP(int xpAtual, int xpProximoNivel, int nivel) que mostra uma barra diferente da de HP: use (preenchido) e (vazio), similar à de HP mas com caracteres diferentes. Ao lado, mostre “Nível X” e a percentagem de progresso.

Solução de referência. A barra de XP segue o mesmo princípio da de HP - razão entre atual e máximo, multiplicado por uma largura fixa - mas troca os caracteres para dar identidade visual. A diferença / versus / (HP) deixa as duas barras distinguíveis ao primeiro olhar, importante quando o HUD fica denso. Calcular a percentagem com inteiros ((xpAtual * 100 / xpProximoNivel).toInt()) evita arredondamentos confusos.

String barraXP(int xpAtual, int xpProximoNivel, int nivel, {int largura = 20}) {
  if (xpProximoNivel <= 0) return 'Nível $nivel (MAX)';

  var razao = xpAtual / xpProximoNivel;
  if (razao > 1) razao = 1;

  var preenchidas = (razao * largura).round();
  var vazias = largura - preenchidas;
  var pct = (razao * 100).toInt();

  var barra = '▓' * preenchidas + '░' * vazias;
  return 'Nível $nivel  [$barra]  $pct%  ($xpAtual/$xpProximoNivel XP)';
}

void main() {
  print(barraXP(0, 100, 1));     // 0%
  print(barraXP(45, 100, 1));    // 45%
  print(barraXP(100, 100, 1));   // 100% - prestes a subir
  print(barraXP(80, 200, 3));    // 40% no nível 3
}

Tratar xpProximoNivel <= 0 é defesa contra “nível máximo” - se o jogador chegou ao último nível, não há próximo, e dividir por zero quebraria tudo. A variação “barra que enche da direita para a esquerda” muda só a montagem da string ('░' * vazias + '▓' * preenchidas), útil para barras de tempo regressivo. Quando os capítulos avançados introduzirem cores ANSI, o mesmo código aceita um wrap '\x1B[33m' + barra + '\x1B[0m' para colorir.

Desafio 6.3 - Caixa de diálogo de NPC (Com bordas especiais)

Crie uma função dialogoNPC(String nomeNPC, String fala) que exibe uma caixa estilizada: o nome do NPC em negrito (ou destacado com cores se em suporte a ANSI) no topo, a fala envolvida com uma borda especial diferente da HUD (use caracteres como , , ).

Solução de referência. Caixas de diálogo precisam de personalidade visual distinta da HUD para o jogador entender, em meio segundo, “isto é alguém falando”. Os arredondados ╭ ╮ ╰ ╯ no lugar de ╔ ╗ ╚ ╝ cumprem isso sem complicar. Quebrar a fala em linhas que cabem na largura disponível (usando split por espaços e contagem cumulativa) deixa diálogos longos legíveis.

List<String> quebrarTexto(String texto, int larguraMax) {
  var palavras = texto.split(' ');
  var linhas = <String>[];
  var atual = StringBuffer();

  for (var palavra in palavras) {
    if (atual.length == 0) {
      atual.write(palavra);
    } else if (atual.length + 1 + palavra.length <= larguraMax) {
      atual.write(' $palavra');
    } else {
      linhas.add(atual.toString());
      atual = StringBuffer(palavra);
    }
  }
  if (atual.length > 0) linhas.add(atual.toString());
  return linhas;
}

void dialogoNPC(String nomeNPC, String fala, {int largura = 50}) {
  var linhas = quebrarTexto(fala, largura - 4);

  print('╭${'─' * (largura - 2)}╮');
  print('│ \x1B[1m$nomeNPC\x1B[0m${' ' * (largura - 3 - nomeNPC.length)}│');
  print('├${'─' * (largura - 2)}┤');
  for (var linha in linhas) {
    print('│ ${linha.padRight(largura - 3)}│');
  }
  print('╰${'─' * (largura - 2)}╯');
}

void main() {
  dialogoNPC(
    'Velho Sábio',
    'A caverna ao norte guarda mais perigos do que ouro, aventureiro. '
    'Mas se voltar com a Chave Enferrujada, posso te mostrar um caminho oculto.',
  );
}

As sequências \x1B[1m e \x1B[0m ligam e desligam negrito em terminais que suportam ANSI; em terminais que não suportam, viram literalmente o texto - feio mas funcional. Em variações, dá para criar um enum TipoNPC { amigavel, hostil, neutro } e mudar o caractere de borda conforme o tipo ( para amigável, reto para hostil, para boss). Quando o cap. 12 (classes) chegar, esse padrão vira um método falar(String texto) na classe NPC, e cada subclasse customiza o visual.

Desafio 6.4 - Mini-mapa do mundo

Usando StringBuffer, desenhe um mini-mapa 5x5 onde @ é o jogador, # são paredes (limites da masmorra), . é chão livre e ? são salas não visitadas. Use a sala atual e salas vizinhas para popular o mapa.

Solução de referência. Mapa 5x5 com posição central fixa para o jogador (@ no meio) e vizinhança derivada das saídas da sala atual. O StringBuffer brilha aqui porque montar a grade célula a célula com + de strings cria centenas de strings descartáveis; o StringBuffer acumula numa só. A leitura fica mais clara separando “qual caractere mostrar nesta posição” do “como imprimir”.

String desenharMiniMapa(
  String salaAtualChave,
  Set<String> visitadas,
) {
  const tamanho = 5;
  const centro = 2;

  var sala = mundo[salaAtualChave]!;
  var saidas = sala['saidas'] as Map<String, dynamic>;

  // mapeia direção -> deslocamento (linha, coluna)
  const deslocamentos = {
    'norte': [-1, 0],
    'sul':   [1, 0],
    'oeste': [0, -1],
    'leste': [0, 1],
  };

  var grade = List.generate(tamanho, (_) => List.filled(tamanho, '.'));

  // bordas
  for (var i = 0; i < tamanho; i++) {
    grade[0][i] = '#';
    grade[tamanho - 1][i] = '#';
    grade[i][0] = '#';
    grade[i][tamanho - 1] = '#';
  }

  // sala atual
  grade[centro][centro] = '@';

  // vizinhas
  for (var entry in saidas.entries) {
    var desloc = deslocamentos[entry.key];
    if (desloc == null) continue;
    var linha = centro + desloc[0];
    var col = centro + desloc[1];
    if (linha < 1 || linha >= tamanho - 1) continue;
    if (col < 1 || col >= tamanho - 1) continue;
    grade[linha][col] = visitadas.contains(entry.value) ? '.' : '?';
  }

  var sb = StringBuffer();
  for (var linha in grade) {
    sb.writeln(linha.join(' '));
  }
  return sb.toString();
}

void main() {
  var visitadas = <String>{'praca'};
  print(desenharMiniMapa('praca', visitadas));
}

Saída típica para a Praça (com saídas norte e leste, nenhuma das duas visitadas):

# # # # #
# . ? . #
# . @ ? #
# . . . #
# # # # #

A armadilha mais comum é List.filled(5, List.filled(5, '.')) - isso cria 5 referências para a mesma lista interna; mudar [0][0] muda todas as linhas. A forma correta é List.generate(5, (_) => List.filled(5, '.')), que gera cinco listas independentes. Para mapas maiores, o passo seguinte é guardar posições 2D explícitas em cada sala ('x': 3, 'y': 7) e renderizar uma janela em volta do jogador - exatamente o que o cap. 15 vai pedir.

Boss Final 6.5 - Tela de morte épica (Game Over)

Crie uma função telaGameOver(String nome, int turnos, int ouro) que monta uma tela de game over elaborada. Inclua: arte ASCII de um túmulo ou caveira, nome do herói caído, quantos turnos sobreviveu, ouro acumulado, e uma última mensagem do tipo “Descansa em paz, herói.” Use box-drawing para tornar impressionante.

Solução de referência. A tela de game over é o momento dramático do jogo - merece ser memorável. Combinar arte ASCII (caveira), painel estruturado com estatísticas e mensagem final pode ser feito tudo num único StringBuffer, mantendo o controle total da apresentação. O truque para a borda alinhada é calcular larguras com padRight/padLeft em vez de tentar contar caracteres manualmente.

String telaGameOver(String nome, int turnos, int ouro) {
  const largura = 50;

  var sb = StringBuffer();
  sb.writeln('╔${'═' * (largura - 2)}╗');
  sb.writeln('║${'GAME OVER'.padLeft((largura - 2 + 9) ~/ 2).padRight(largura - 2)}║');
  sb.writeln('╠${'═' * (largura - 2)}╣');
  sb.writeln('║${''.padRight(largura - 2)}║');

  // caveira ASCII
  const caveira = [
    '         _______',
    '        /       \\\\',
    '       |  X   X  |',
    '       |    ^    |',
    '       |  \\___/  |',
    '        \\_______/',
  ];
  for (var linha in caveira) {
    sb.writeln('║${linha.padRight(largura - 2)}║');
  }

  sb.writeln('║${''.padRight(largura - 2)}║');
  sb.writeln('║${'Aqui jaz:'.padRight(largura - 2)}║');
  sb.writeln('║${'  $nome'.padRight(largura - 2)}║');
  sb.writeln('║${''.padRight(largura - 2)}║');
  sb.writeln('║${'  Sobreviveu $turnos turnos'.padRight(largura - 2)}║');
  sb.writeln('║${'  Acumulou $ouro moedas de ouro'.padRight(largura - 2)}║');
  sb.writeln('║${''.padRight(largura - 2)}║');
  sb.writeln('╠${'═' * (largura - 2)}╣');
  sb.writeln('║${'Descansa em paz, herói.'.padLeft((largura - 2 + 23) ~/ 2).padRight(largura - 2)}║');
  sb.writeln('╚${'═' * (largura - 2)}╝');

  return sb.toString();
}

void main() {
  print(telaGameOver('Aldric, o Brando', 47, 1342));
}

A centralização via padLeft((largura + tamanho_texto) ~/ 2) é o truque clássico: você empurra o texto para a direita pela metade do espaço sobrando, depois preenche o resto à direita. Funciona melhor que tentar calcular o offset manualmente porque padRight(largura) cobre qualquer arredondamento. Em variações, adicione uma “estatística de morte” (causa: “morto por orc na Caverna do Norte”) usando a sala onde o HP zerou - aí a função vira telaGameOver(jogador, ultimaSala, ultimoInimigo). Quando o cap. 18 introduzir saves, esse mesmo formato é o que vai para o arquivo mortes.txt como obituário do herói.


Capítulo 7 - O game loop, o coração do jogo

Desafio 7.1 - Eventos aleatórios (Suspense)

Adicione um evento aleatório a cada turno com 20% de probabilidade. Use import 'dart:math' e Random().nextInt(100) < 20 para decidir. Exemplos: “Você ouve passos distantes…”, “Um sopro frio passa por você”, “Algo se move na sombra”. Mostre apenas quando o evento ocorrer.

Solução de referência. Eventos atmosféricos não mudam mecânica de jogo - mudam percepção. Um susto a cada cinco turnos faz o jogador sentir que a masmorra está viva. A regra dos 20% (nextInt(100) < 20) é melhor que nextInt(5) == 0 porque, lendo o código, fica claro que é “vinte por cento”, não “um em cinco” (que dá no mesmo numericamente mas é menos imediato).

import 'dart:math';

final _rng = Random();

const atmosfericos = [
  'Você ouve passos distantes...',
  'Um sopro frio passa por você.',
  'Algo se move nas sombras à sua direita.',
  'Uma gota d\'água ecoa em algum lugar.',
  'O cheiro de enxofre fica mais forte.',
  'Você sente que está sendo observado.',
  'O chão range sob seus pés.',
];

void talvezEventoAtmosferico() {
  if (_rng.nextInt(100) < 20) {
    var msg = atmosfericos[_rng.nextInt(atmosfericos.length)];
    print('  > $msg');
  }
}

A função fica isolada e é chamada num único ponto - normalmente logo depois do jogador executar a ação do turno e antes do prompt do próximo. Isso garante que o evento parece reação ao mundo, não pré-condição. Variações fáceis: filtrar a lista por sala (mensagens de “água pingando” só fazem sentido em cavernas), usar Set para não repetir o mesmo evento duas vezes seguidas, ou aumentar a chance em salas marcadas como 'tensao': true. Cuidado com 20% sentir-se “cedo demais” - se cada mensagem custa atenção do jogador, calibrar para 10-15% costuma ser mais agradável; teste e ajuste.

Desafio 7.2 - Comando examinar (Pistas escondidas)

Adicione um campo detalhes (texto longo) a cada sala além da descrição breve. O comando "examinar" ou "x" mostra esses detalhes. Serve para esconder pistas e informações extras para jogadores curiosos investigarem.

Solução de referência. Dois níveis de descrição - “olhar rápido” (entrada na sala) e “olhar atento” (comando examinar) - é o padrão clássico de aventuras de texto. A descrição breve responde “onde estou?”; o detalhe responde “o que mais tem aqui?”. Esconder pistas, números (códigos para portas trancadas), nomes de NPCs ou itens secretos no campo detalhes recompensa quem investiga sem punir quem corre.

mundo['praca'] = {
  'nome': 'Praça Central',
  'descricao': 'Uma praça empoeirada com uma fonte seca no centro.',
  'detalhes': 'Olhando com mais atenção, você nota inscrições antigas '
      'na borda da fonte: símbolos em três anéis concêntricos. '
      'Um corvo observa do telhado da capela à leste.',
  'saidas': {'norte': 'camara', 'leste': 'biblioteca'},
  'itens': <String>[],
};

void comandoExaminar(String salaAtual) {
  var sala = mundo[salaAtual]!;
  var detalhes = sala['detalhes'] as String?;
  if (detalhes == null || detalhes.isEmpty) {
    print('Você olha com atenção, mas nada chama atenção.');
    return;
  }
  print('Você olha com atenção...');
  print(detalhes);
}

// no parser do game loop:
String? parsearComando(String entrada) {
  var c = entrada.toLowerCase().trim();
  if (c == 'x' || c == 'examinar' || c == 'olhar') return 'examinar';
  // ... outros comandos
  return null;
}

A redundância “olhar / examinar / x” cobre estilos de jogador: quem prefere verbo curto, quem prefere completo, quem prefere uma tecla. Para sala sem detalhes, mostrar uma mensagem genérica é melhor que omitir - silêncio frustra. Em variações, dá para fazer detalhes condicionais: só aparece se o jogador tem tocha (sala escura), ou só na segunda visita (“agora você nota…”). A estrutura natural é um Map<String, String> em vez de uma string única, com chaves como 'sem_luz', 'com_luz', 'segunda_visita'.

Desafio 7.3 - Ambiente hostil (HP dinâmico)

Cada vez que o jogador entrar numa sala com descrição contendo “escuro”, “frio”, “úmido” ou “perigoso”, perca 5 HP automaticamente. Use .contains(). Se HP chegar a 0, exiba a tela de game over. Isso torna algumas salas mais perigosas que outras: ambiente vs jogador.

Solução de referência. Dano ambiental por palavra-chave é uma forma simples de fazer o mundo “morder” sem inventar sistema de combate. O ponto delicado é dar feedback: o jogador precisa saber que perdeu HP por estar num lugar ruim, ou vai achar que o número está bugado. Mostrar uma mensagem específica (“O frio rouba sua energia (-5 HP)”) faz a mecânica ficar legível.

const palavrasPerigosas = ['escuro', 'frio', 'úmido', 'perigoso'];

int aplicarDanoAmbiental(int hpAtual, String descricaoSala) {
  var desc = descricaoSala.toLowerCase();
  var encontradas = palavrasPerigosas.where(desc.contains).toList();

  if (encontradas.isEmpty) return hpAtual;

  var dano = 5;
  var motivo = encontradas.first;
  print('O ambiente $motivo te machuca. -$dano HP.');
  return (hpAtual - dano).clamp(0, 100);
}

// usado quando o jogador entra numa sala:
void entrarNaSala(String chave, Map<String, dynamic> jogador) {
  var sala = mundo[chave]!;
  jogador['salaAtual'] = chave;
  print('== ${sala['nome']} ==');
  print(sala['descricao']);

  var hpAntes = jogador['hp'] as int;
  var hpDepois = aplicarDanoAmbiental(hpAntes, sala['descricao'] as String);
  jogador['hp'] = hpDepois;

  if (hpDepois <= 0) {
    print(telaGameOver(
      jogador['nome'] as String,
      jogador['turnos'] as int,
      jogador['ouro'] as int,
    ));
    exit(0);  // import 'dart:io'
  }
}

O clamp(0, 100) impede HP negativo - útil quando o dano vai aparecer em barras. A escolha de .contains() é genérica demais para produção: uma sala que mencione “não é perigoso” perderia HP. Para o livro, isso ainda está OK; para um jogo real, o melhor é dar a cada sala um campo 'perigos': ['frio', 'escuro'] explícito, separando narrativa de mecânica. Esse refactor aparece naturalmente quando o cap. 13 introduzir class Sala.

Desafio 7.4 - Tela de estatísticas finais

Ao sair do jogo, exiba uma tabela formatada com: turnos jogados, salas visitadas (conte as únicas), itens coletados, ouro final e HP sobrevivido. Use box-drawing e formatação visual.

Solução de referência. A tela final fecha a sessão com sensação de “olha tudo o que rolou”. Mais que números, ela é o jogador revendo a própria aventura. O ingrediente sutil é o alinhamento à esquerda dos rótulos e à direita dos valores, com pontilhado entre eles - lê como uma planilha, mas elegante.

String telaEstatisticas({
  required String nome,
  required int turnos,
  required Set<String> salasVisitadas,
  required int itensColetados,
  required int ouroFinal,
  required int hpFinal,
}) {
  const largura = 50;

  String linha(String rotulo, String valor) {
    var disponivel = largura - 4 - rotulo.length - valor.length;
    if (disponivel < 3) disponivel = 3;
    var pontos = '.' * disponivel;
    return '║ $rotulo $pontos $valor ║';
  }

  var sb = StringBuffer();
  sb.writeln('╔${'═' * (largura - 2)}╗');
  sb.writeln('║${' RESUMO DA AVENTURA '.padLeft((largura + 18) ~/ 2).padRight(largura - 2)}║');
  sb.writeln('╠${'═' * (largura - 2)}╣');
  sb.writeln(linha('Herói', nome));
  sb.writeln(linha('Turnos', '$turnos'));
  sb.writeln(linha('Salas visitadas', '${salasVisitadas.length}'));
  sb.writeln(linha('Itens coletados', '$itensColetados'));
  sb.writeln(linha('Ouro acumulado', '$ouroFinal moedas'));
  sb.writeln(linha('HP final', '$hpFinal/100'));
  sb.writeln('╚${'═' * (largura - 2)}╝');
  return sb.toString();
}

void main() {
  print(telaEstatisticas(
    nome: 'Aldric',
    turnos: 38,
    salasVisitadas: {'praca', 'camara', 'biblioteca', 'tesouro'},
    itensColetados: 7,
    ouroFinal: 1342,
    hpFinal: 67,
  ));
}

Set<String> para “salas visitadas” garante que entrar duas vezes na Praça conta uma só - exatamente o que o desafio pede. Em variações, agregue mais: “Maior dano em um turno”, “NPC mais conversado”, “Item mais valioso encontrado”. Essas estatísticas mais finas saem de naturalmente quando o jogo crescer; aqui o foco é o esqueleto. Quando o cap. 18 (persistência) introduzir leaderboard, esta mesma função alimenta o arquivo de scores.

Boss Final 7.5 - Sistema de diálogo com NPC (Velho Sábio)

Adicione um NPC chamado “Velho Sábio” numa sala especial “Taverna”. O comando "falar" inicia um diálogo com 3 opções de respostas (use número ou letra). Uma das respostas revela uma dica sobre uma sala secreta. Se o jogador tiver a “Chave Enferrujada” no inventário quando resolver voltar, uma nova saída aparece na “Câmara Secreta” com ouro ou uma arma valiosa.

Solução de referência. Diálogos ramificados são o salto qualitativo do jogo: deixam de ser “navegar pelo mapa” e viram “interagir com personagens”. A estrutura mínima é (1) entrar no diálogo, (2) listar opções, (3) ler escolha, (4) executar consequência, (5) talvez voltar ao loop. O Velho Sábio também ilustra um padrão importante - estado de mundo controlado por flag: depois de ouvir a dica, uma nova rota aparece, e isso fica registrado num campo do jogador, não no mapa.

import 'dart:io';

void dialogoVelhoSabio(Map<String, dynamic> jogador, List<String> inventario) {
  print('O Velho Sábio se vira lentamente para você.');
  print('— Aventureiro, sente-se. O que você busca?');
  print('');
  print('  1) Conselhos sobre a masmorra');
  print('  2) Histórias antigas');
  print('  3) Tesouros esquecidos');
  print('  4) Sair da conversa');
  stdout.write('> ');

  var escolha = (stdin.readLineSync() ?? '').trim();

  switch (escolha) {
    case '1':
      print('— Cuide com os corredores escuros. O frio mata mais que espada.');
    case '2':
      print('— Há muito tempo, este reino era próspero...');
      print('— Mas o ouro atrai sombras. E nem todo brilho merece ser perseguido.');
    case '3':
      print('— Atrás da capela, há uma porta selada por séculos.');
      print('— Uma chave enferrujada está enterrada no jardim. '
            'Encontre-a, e o caminho se abre.');
      jogador['ouviuDicaSabio'] = true;
    case '4':
      print('— Vá com cuidado, então.');
    default:
      print('— Sua escolha não me alcança. Tente de novo.');
      return dialogoVelhoSabio(jogador, inventario);
  }
}

void talvezAbrirCamaraSecreta(
  Map<String, dynamic> jogador,
  List<String> inventario,
) {
  var ouviu = jogador['ouviuDicaSabio'] == true;
  var temChave = inventario.contains('Chave Enferrujada');
  var jaAberta = (mundo['capela']!['saidas'] as Map).containsKey('descer');

  if (ouviu && temChave && !jaAberta) {
    (mundo['capela']!['saidas'] as Map)['descer'] = 'camaraSecreta';
    mundo['camaraSecreta'] = {
      'nome': 'Câmara Secreta',
      'descricao': 'Uma sala esquecida, com cofre dourado no centro.',
      'saidas': {'subir': 'capela'},
      'itens': <String>['Espada Élfica', 'Saco de Ouro'],
    };
    print('A Chave Enferrujada brilha. Você ouve um clique vindo de baixo da capela.');
  }
}

switch com case '1': (sem break, no Dart 3+) é a forma idiomática moderna. A função recursiva return dialogoVelhoSabio(...) no default mantém o jogador no diálogo até escolher uma opção válida - alternativa: while (true) com break no fim de cada caso. A separação entre dialogoVelhoSabio (conversar) e talvezAbrirCamaraSecreta (consequência de mundo) é importante: a primeira só registra uma flag; a segunda, chamada a cada turno ou quando o jogador entra na capela, verifica as condições e abre a passagem. Esse desacoplamento facilita testar cada parte em isolado e é o gérmen do padrão Observer que vai aparecer no cap. 22.


Parte II - Sangue, Ouro e Aço

Capítulo 8 - Classes: dando vida ao jogador

Desafio 8.1 - Classe Item (Objeto com peso e descrição)

Crie uma classe Item com campos nome, descricao e peso (em gramas). Substitua as strings no inventário do jogador por objetos Item. Atualize pegarItem e largarItem para usar Item em vez de String. Implemente toString() para exibir o item de forma legível (exemplo: “Espada Curta (500g)”).

Solução de referência. Transformar String em Item é o primeiro passo para dar gramática ao inventário. Em vez de “Espada Curta” como literal solto, agora temos um objeto que carrega seus próprios atributos. O ganho prático aparece nos próximos desafios: peso, descrição, comparação. Sobrescrever toString() é cortesia para depuração - print(item) mostra algo informativo em vez de Instance of 'Item'.

class Item {
  final String nome;
  final String descricao;
  final int peso;  // em gramas

  Item(this.nome, this.descricao, this.peso);

  @override
  String toString() => '$nome (${peso}g)';
}

bool pegarItem(Item item, List<Item> inventario, List<Item> itensSala) {
  if (!itensSala.contains(item)) {
    print('Não há ${item.nome} aqui.');
    return false;
  }
  itensSala.remove(item);
  inventario.add(item);
  print('Você pegou $item.');  // usa toString automaticamente
  return true;
}

void main() {
  var espada = Item('Espada Curta', 'Lâmina simples mas bem afiada.', 500);
  var tocha = Item('Tocha', 'Madeira embebida em piche.', 300);
  print(espada);  // Espada Curta (500g)
  print(tocha);   // Tocha (300g)
}

A escolha de final nos três campos é deliberada: itens não mudam de nome nem de peso depois de criados. Mais tarde, quando aparecer condição “espada quebrada” (peso muda, ou um sufixo é adicionado), o caminho idiomático é o desafio 9.5 - copyWith - em vez de mutar o item original. A armadilha comum é deixar peso como double: somar pesos com ponto flutuante gera erros de arredondamento (0.1 + 0.2 != 0.3); usar inteiro em gramas evita o problema todo.

Desafio 8.2 - Peso e limite de carga

Adicione um campo pesoMaximo ao Jogador (padrão: 5000 gramas). O método pegarItem deve verificar se adicionar o novo item ultrapassaria o limite. Se ultrapassar, recuse com mensagem clara. Crie um getter pesoAtual que calcula o peso total do inventário em tempo real.

Solução de referência. Limite por peso (em vez de “número de slots”) é mais realista e dá margem para escolhas estratégicas - largar uma corrente de ferro para caber uma poção. O getter calcula sob demanda; não precisamos manter um cache porque adicionar 5-10 itens por turno e somar é trivialmente rápido. Em jogos grandes (centenas de itens), aí sim vale cache; aqui simplicidade ganha.

class Jogador {
  String nome;
  int hp;
  int ataque;
  int ouro;
  int pesoMaximo;
  final List<Item> inventario = [];

  Jogador(this.nome, {
    this.hp = 100,
    this.ataque = 5,
    this.ouro = 0,
    this.pesoMaximo = 5000,
  });

  int get pesoAtual => inventario.fold(0, (soma, item) => soma + item.peso);

  bool pegarItem(Item item) {
    if (pesoAtual + item.peso > pesoMaximo) {
      print('Pesado demais. Você carrega ${pesoAtual}g de ${pesoMaximo}g; '
            '${item.nome} pesa ${item.peso}g.');
      return false;
    }
    inventario.add(item);
    print('Você pegou $item. Carga: ${pesoAtual}g/${pesoMaximo}g.');
    return true;
  }
}

fold(0, ...) é a forma idiomática de “reduzir” uma lista a um único valor - aqui, a soma dos pesos. Para 5-20 itens, sua performance é indistinguível de um for manual, mas a leitura é mais declarativa: “fold inicia em 0 e acumula soma + item.peso”. Variação útil é dar peso negativo para “mochilas mágicas” (carregar uma mochila aumenta pesoMaximo em vez de reduzir a carga); para evitar abuso, capa pesoAtual >= 0 no getter via max(0, ...).

Desafio 8.3 - Método toString robusto para Sala

Implemente toString() em Sala que mostra: nome, saídas disponíveis e quantidade de itens. Para debug, ao mudar de sala, imprima print(novaSala) para validar que o estado está correto. Formato exemplo: "Sala(Praça Central, saídas: [n, l, s], itens: 2)".

Solução de referência. toString() para Sala serve dois mestres: depuração (rastrear movimentação no log) e telemetria (registrar onde o jogador passou). O segredo é deixar o resumo compacto - você quer ver dez salas numa página, não dez parágrafos. As iniciais das direções (n, s, l, o) economizam linha sem perder informação.

class Sala {
  final String nome;
  final String descricao;
  final Map<String, String> saidas;  // direcao -> id da sala destino
  final List<Item> itens;

  Sala({
    required this.nome,
    required this.descricao,
    Map<String, String>? saidas,
    List<Item>? itens,
  })  : saidas = saidas ?? {},
        itens = itens ?? [];

  @override
  String toString() {
    var dirs = saidas.keys.map((d) => d.substring(0, 1)).join(', ');
    return 'Sala($nome, saídas: [$dirs], itens: ${itens.length})';
  }
}

void main() {
  var praca = Sala(
    nome: 'Praça Central',
    descricao: 'Praça empoeirada.',
    saidas: {'norte': 'camara', 'leste': 'biblio', 'sul': 'esgoto'},
    itens: [Item('Moeda', 'Pequena moeda de cobre.', 5)],
  );
  print(praca);  // Sala(Praça Central, saídas: [n, l, s], itens: 1)
}

O substring(0, 1) pega só a primeira letra de cada direção. Para mundos com mais de quatro direções (cima, baixo, dentro), mantenha um mapa {'norte': 'N', 'cima': 'C'} explícito para evitar colisão. Em variações de produção, é comum ter Sala.toString() formal (como acima) e um método Sala.toShortString() para casos onde só o nome importa - separar concerns evita que mudar uma quebre a outra.

Desafio 8.4 - Método descrever para renderização

Adicione um método String descrever() em Sala que retorna uma descrição completa e formatada (usando StringBuffer): nome com moldura, descrição longa, saídas listadas, itens no chão com seus pesos. Substitua a função exibirSala() do Capítulo 7 por uma chamada a sala.descrever().

Solução de referência. Mover a renderização para dentro da classe (sala.descrever()) é o tipo de refactor que faz o resto do código encolher visivelmente. O main antes precisava saber montar caixa, listar saídas, listar itens; agora chama um método e pronto. Esse é o padrão Information Expert (a classe que tem os dados é a que sabe formatá-los) e aparece de novo em Jogador.ficha(), Inimigo.descrever().

class Sala {
  // ... campos do desafio 8.3 ...

  String descrever() {
    var sb = StringBuffer();
    var largura = 50;
    sb.writeln('╔${'═' * (largura - 2)}╗');
    sb.writeln('║ ${nome.padRight(largura - 3)}║');
    sb.writeln('╠${'═' * (largura - 2)}╣');
    sb.writeln('║ ${descricao.padRight(largura - 3)}║');

    if (saidas.isNotEmpty) {
      var dirs = saidas.keys.join(', ');
      sb.writeln('║ Saídas: ${dirs.padRight(largura - 11)}║');
    }

    if (itens.isNotEmpty) {
      sb.writeln('║ No chão:${''.padRight(largura - 11)}║');
      for (var item in itens) {
        sb.writeln('║   - ${item.toString().padRight(largura - 7)}║');
      }
    }

    sb.writeln('╚${'═' * (largura - 2)}╝');
    return sb.toString();
  }
}

// no game loop:
void entrarNaSala(Sala sala, Jogador jogador) {
  jogador.salaAtual = sala;
  print(sala.descrever());
}

StringBuffer em vez de concatenação de Strings não é micro-otimização aqui - é hábito que escala. Quando esta função for chamada 50+ vezes por sessão (cada movimento), criar e descartar strings pequenas custa garbage collection. StringBuffer aloca uma vez e escreve em sequência. Em variações, descrever pode receber parâmetros: sala.descrever(comDetalhes: true) para incluir detalhes longos do desafio 7.2, ou sala.descrever(ocupantes: [npc1, npc2]) para listar NPCs presentes na sala.

Boss Final 8.5 - Classe MundoTexto (Gerenciador de mundo)

Crie uma classe MundoTexto que encapsula o Map<String, Sala> e fornece métodos: Sala? obterSala(String id), void adicionarSala(Sala sala), List<String> salasConectadas(String id) que retorna as salas alcançáveis. Substitua o mapa global mundoSalas por uma instância var mundo = MundoTexto(); e use-a para todas as operações do jogo.

Solução de referência. Encapsular o Map<String, Sala> numa classe MundoTexto resolve dois problemas: (1) elimina a variável global espalhada pelo código, (2) abre porta para múltiplos mundos coexistirem (mundo principal, masmorra do sonho, mundo do tutorial). A classe vira o “departamento de geografia” do jogo. Note que salasConectadas é uma operação típica de grafos - pode crescer para BFS/DFS quando o cap. 24 introduzir pathfinding.

class MundoTexto {
  final Map<String, Sala> _salas = {};

  Sala? obterSala(String id) => _salas[id];

  void adicionarSala(String id, Sala sala) {
    if (_salas.containsKey(id)) {
      throw ArgumentError('Sala "$id" já existe no mundo.');
    }
    _salas[id] = sala;
  }

  List<String> salasConectadas(String id) {
    var sala = _salas[id];
    if (sala == null) return [];
    return sala.saidas.values.toList();
  }

  int get total => _salas.length;

  Iterable<String> get ids => _salas.keys;
}

void main() {
  var mundo = MundoTexto();
  mundo.adicionarSala('praca', Sala(
    nome: 'Praça Central',
    descricao: 'Empoeirada.',
    saidas: {'norte': 'camara'},
  ));
  mundo.adicionarSala('camara', Sala(
    nome: 'Câmara do Tesouro',
    descricao: 'Cofre dourado.',
    saidas: {'sul': 'praca'},
  ));

  print('Total: ${mundo.total} salas');
  print('Conectadas a praca: ${mundo.salasConectadas("praca")}');

  var sala = mundo.obterSala('praca');
  print(sala?.descrever() ?? 'Sala não encontrada.');
}

O _salas com underscore é privado ao arquivo - quem usa MundoTexto não consegue acessar a Map direto, só pelos métodos. Isso permite, no futuro, trocar a estrutura interna (talvez SplayTreeMap para ordenar, ou Map<String, WeakReference<Sala>> para gerência de memória) sem quebrar o resto do código. A armadilha aqui é abrir o mapa devolvendo _salas num getter público sem Map.unmodifiable(_salas) - aí a encapsulação é só fachada. Quando o capítulo 19 trouxer JSON, MundoTexto.toJson() e MundoTexto.fromJson() viram métodos naturais dessa classe.


Capítulo 9 - Construtores e encapsulamento

Desafio 9.1 - Sala com API protegida

Torne os campos de Sala que são listas (itens) verdadeiramente protegidos com _: _itens. Adicione métodos públicos adicionarItem(String) e removerItem(String) em vez de expor a lista diretamente. Crie um getter List<String> get itens => List.unmodifiable(_itens) para leitura segura.

Solução de referência. O underscore (_itens) é a única marca de visibilidade no Dart - campo privado ao arquivo, não à classe. O contrato muda do “qualquer um pode mutar a lista” para “leitura é livre, escrita passa por método”. List.unmodifiable é a camada de defesa em tempo de execução: se alguém driblar e tentar sala.itens.add(...), o Dart lança UnsupportedError. Isso evita o tipo de bug em que um add perdido em outro módulo bagunça o estado do mundo.

class Sala {
  final String nome;
  final String descricao;
  final List<String> _itens;

  Sala(this.nome, this.descricao, [List<String>? itensIniciais])
      : _itens = List.of(itensIniciais ?? const []);

  List<String> get itens => List.unmodifiable(_itens);

  void adicionarItem(String item) {
    _itens.add(item);
  }

  bool removerItem(String item) {
    return _itens.remove(item);
  }
}

void main() {
  var sala = Sala('Praça', 'Empoeirada.', ['Moeda']);
  print(sala.itens);  // [Moeda]

  sala.adicionarItem('Tocha');
  print(sala.itens);  // [Moeda, Tocha]

  // Tentar mutar diretamente lança erro:
  try {
    sala.itens.add('Trapaça');
  } catch (e) {
    print('Bloqueado: $e');
  }
}

O List.of(itensIniciais ?? const []) no construtor faz uma cópia defensiva - se alguém passar uma lista e depois alterá-la externamente, a Sala continua íntegra. Sem isso, o construtor compartilharia a referência e abriria flanco para bug. A armadilha sutil é usar _itens = itensIniciais ?? [] (sem List.of), que mantém o vazamento de referência. Para coleções “pesadas” (centenas de elementos), List.unmodifiable aloca um wrapper a cada chamada do getter; nesses casos, cachear o wrapper num campo late final é o próximo passo.

Desafio 9.2 - Construtores nomeados de dificuldade

Crie Jogador.facil(nome), Jogador.normal(nome) e Jogador.dificil(nome) com stats progressivamente mais altos (HP: 50/100/150, ataque: 3/5/10, ouro inicial: 0/50/200). Teste cada um imprimindo toString() e verificando se os stats fazem sentido.

Solução de referência. Construtores nomeados (Jogador.facil, Jogador.normal, etc.) são presets - o jeito idiomático de “fábrica simples” em Dart. Eles delegam ao construtor principal via : this(...), evitando duplicação. A regra é: construtor principal recebe tudo configurável; nomeados escolhem valores temáticos. Note a inversão de “dificuldade” aqui: HP mais alto em “fácil” porque o jogador aguenta mais, oposto de “fácil” significar “pouco dano causado”.

class Jogador {
  String nome;
  int hp;
  int ataque;
  int ouro;

  Jogador({
    required this.nome,
    this.hp = 100,
    this.ataque = 5,
    this.ouro = 0,
  });

  Jogador.facil(String nome)    : this(nome: nome, hp: 150, ataque: 10, ouro: 200);
  Jogador.normal(String nome)   : this(nome: nome, hp: 100, ataque: 5,  ouro: 50);
  Jogador.dificil(String nome)  : this(nome: nome, hp: 50,  ataque: 3,  ouro: 0);

  @override
  String toString() => 'Jogador($nome, HP=$hp, ATK=$ataque, ouro=$ouro)';
}

void main() {
  print(Jogador.facil('Aldric'));    // Jogador(Aldric, HP=150, ATK=10, ouro=200)
  print(Jogador.normal('Iris'));     // Jogador(Iris, HP=100, ATK=5, ouro=50)
  print(Jogador.dificil('Mariel'));  // Jogador(Mariel, HP=50, ATK=3, ouro=0)
}

A sintaxe : this(...) no construtor nomeado é a forma de chamar o construtor principal, evitando reescrever o this.hp = hp para cada nível. Se amanhã o construtor principal validar nome (if (nome.length < 2) throw...), os três presets herdam a validação automaticamente. Variações úteis: Jogador.tutorial(String nome) com HP 999 (impossível morrer), Jogador.veteran(Jogador anterior) que copia stats de um save antigo. Tudo cabe no mesmo modelo.

Desafio 9.3 - Validação de movimentação

Refatore o método moverPara(String novaSalaId) para aceitar também o MundoTexto (ou Map<String, Sala>) do jogo. Valide se a sala destino realmente existe antes de permitir o movimento. Se não existir, lance uma Exception ou retorne bool false.

Solução de referência. A escolha entre throw e return false define o contrato da função. Se a entrada inválida vem do jogador (digitou direção que não existe), return false + mensagem é o jeito amigável. Se a entrada vem do código interno (algum bug tentou mover para sala fantasma), throw é melhor porque expõe o bug rápido. Aqui a função recebe input do parser, então bool faz mais sentido - e o throw fica para condição que indica corrupção real.

class Jogador {
  String nome;
  Sala? salaAtual;
  // ... outros campos ...

  Jogador(this.nome);

  bool moverPara(String direcao, MundoTexto mundo) {
    var atual = salaAtual;
    if (atual == null) {
      throw StateError('Jogador "$nome" não tem sala atual.');
    }

    var idDestino = atual.saidas[direcao];
    if (idDestino == null) {
      print('Não há saída para $direcao a partir de ${atual.nome}.');
      return false;
    }

    var destino = mundo.obterSala(idDestino);
    if (destino == null) {
      // Isso indica bug: mapa aponta para sala inexistente.
      throw StateError(
        'Sala "$idDestino" referenciada por "${atual.nome}" não existe no mundo.',
      );
    }

    salaAtual = destino;
    print('Você caminha para ${destino.nome}.');
    return true;
  }
}

A distinção - return false para “jogador errou”, throw para “mundo corrompido” - é o que separa erro esperado de bug. Para o jogador, o false permite ao loop de jogo decidir o que fazer (tentar de novo, abortar). Para o bug, o throw para o programa antes que o jogador veja estados inválidos. Variação: para playtesting, dá para trocar throw por print('BUG: ...') + return false e logar erros sem parar - mas em produção, falhar rápido protege a integridade do save.

Desafio 9.4 - Construtor deArquivo resiliente (Carregamento seguro)

Aperfeiçoe o construtor Jogador.deArquivo(Map<String, dynamic> dados) com tratamento de erros: se uma chave estiver faltando ou for do tipo errado, use valores padrão em vez de crashar. Use casting seguro: (dados['hp'] as int?) ?? 100.

Solução de referência. Quando você carrega de arquivo (JSON, save, config), tudo é dynamic até prova em contrário. O as int? em vez de as int é o tique-chave: se o valor estiver presente e for int, vira int? (não-nulo); se for outra coisa (string, null, faltando), as int? retorna null, e o ?? provê o padrão. Sem isso, um save corrompido derruba o jogo na primeira linha.

class Jogador {
  String nome;
  int hp;
  int ataque;
  int ouro;

  Jogador({
    required this.nome,
    this.hp = 100,
    this.ataque = 5,
    this.ouro = 0,
  });

  Jogador.deArquivo(Map<String, dynamic> dados)
      : nome    = (dados['nome'] as String?) ?? 'Anônimo',
        hp      = (dados['hp'] as int?) ?? 100,
        ataque  = (dados['ataque'] as int?) ?? 5,
        ouro    = (dados['ouro'] as int?) ?? 0 {
    // valida ranges depois da inicialização
    if (hp < 0) hp = 0;
    if (hp > 999) hp = 999;
    if (ataque < 0) ataque = 0;
  }

  @override
  String toString() => 'Jogador($nome, HP=$hp, ATK=$ataque, ouro=$ouro)';
}

void main() {
  // save completo
  print(Jogador.deArquivo({
    'nome': 'Aldric', 'hp': 87, 'ataque': 7, 'ouro': 432,
  }));

  // save corrompido (campos faltando)
  print(Jogador.deArquivo({'nome': 'Sobrevivente'}));
  // -> Jogador(Sobrevivente, HP=100, ATK=5, ouro=0)

  // save com tipos errados (number como string)
  print(Jogador.deArquivo({
    'nome': 'Estranho', 'hp': '87',  // string em vez de int
  }));
  // -> Jogador(Estranho, HP=100, ATK=5, ouro=0)  -- hp caiu no fallback
}

A validação de ranges depois da lista de inicializadores (no corpo {...} do construtor) cobre o caso “campo presente mas absurdo” - HP de -42 ou 999999. Esse padrão é uma camada extra de defesa: o as int? valida o tipo, o if (hp < 0) valida o intervalo. Variação para produção: em vez de silenciosamente trocar valores, logue avisos (if (hp < 0) { print('WARN: hp negativo, ajustando para 0'); hp = 0; }) - útil para detectar saves corrompidos ao longo do tempo.

Boss Final 9.5 - Padrão Copy-With (Imutabilidade)

Crie ou refatore Sala para ser completamente imutável com final em todos os campos. Implemente um método Sala copyWith({List<String>? itens, bool? temLoja}) que retorna uma nova Sala com as mudanças aplicadas. Demonstre com uma sequência: sala1 → adiciona item (cria sala2) → remove item (cria sala3).

Solução de referência. copyWith é o jeito Dart/Flutter de modelar mudança em objetos imutáveis: em vez de mutar, criar uma versão nova com diferenças aplicadas. O ganho é enorme - histórico de estados grátis (cada copyWith deixa o anterior intacto), comparações por valor mais previsíveis, e thread-safety automática. Funciona bem para entidades de domínio (Sala, Jogador, Item) e mal para coisas com identidade forte (uma Conexão ao banco de dados).

class Sala {
  final String nome;
  final String descricao;
  final List<String> itens;
  final bool temLoja;

  const Sala({
    required this.nome,
    required this.descricao,
    this.itens = const [],
    this.temLoja = false,
  });

  Sala copyWith({
    String? nome,
    String? descricao,
    List<String>? itens,
    bool? temLoja,
  }) {
    return Sala(
      nome: nome ?? this.nome,
      descricao: descricao ?? this.descricao,
      itens: itens ?? this.itens,
      temLoja: temLoja ?? this.temLoja,
    );
  }

  @override
  String toString() =>
      'Sala($nome, itens: $itens, loja: $temLoja)';
}

void main() {
  var sala1 = Sala(
    nome: 'Praça',
    descricao: 'Empoeirada.',
    itens: ['Moeda'],
  );

  // sala2 = sala1 + Tocha
  var sala2 = sala1.copyWith(itens: [...sala1.itens, 'Tocha']);

  // sala3 = sala2 sem Moeda
  var sala3 = sala2.copyWith(
    itens: sala2.itens.where((i) => i != 'Moeda').toList(),
  );

  print(sala1);  // Sala(Praça, itens: [Moeda], loja: false)
  print(sala2);  // Sala(Praça, itens: [Moeda, Tocha], loja: false)
  print(sala3);  // Sala(Praça, itens: [Tocha], loja: false)
}

O detalhe nome ?? this.nome é o que faz copyWith funcionar com “passar só o que muda”: se você chama sala.copyWith(itens: [...]), todos os outros parâmetros são null e o ?? mantém o valor original. A armadilha clássica é querer “limpar” um campo opcional - copyWith(saidas: null) não funciona porque o método não distingue “não passou” de “passou null”. A solução padrão é trocar por copyWith({Sentinel saidas = Sentinel.unchanged}) ou usar a biblioteca freezed que gera tudo isso automaticamente.

Para o livro, a versão manual é suficiente e didática. Quando o cap. 26 introduzir Riverpod, esse mesmo padrão vira a base de todo o gerenciamento de estado da UI: mudança = state.copyWith(...), e o framework reconstrói só o que precisa.


Capítulo 10 - Herança: a família dos inimigos

Desafio 10.1 - Novo tipo de inimigo (Orc)

Crie uma classe Orc que estende Inimigo. Dê-lhe: HP=12, maxHp=12, ataque=5, símbolo=‘O’, e uma descrição agressiva (“Um orc musculoso com fome de batalha”). Sobrescreva descreverAcao() para retornar algo temível como “O orc rosna e levanta sua clava!”. Teste criando uma instância e imprimindo.

Solução de referência. Herança aqui serve para escapar do anti-padrão de “switch gigante por tipo”: em vez de if (inimigo.tipo == "orc") { ... } else if (...), cada subclasse encapsula o próprio comportamento. O super(...) no construtor do Orc passa adiante os argumentos para o construtor de Inimigo, evitando reescrever a inicialização. @override é dispensável tecnicamente mas obrigatório por estilo - documenta intenção e o analyzer reclama se a assinatura não bater.

class Inimigo {
  String nome;
  int hp;
  int maxHp;
  int ataque;
  String simbolo;
  String descricao;

  Inimigo({
    required this.nome,
    required this.hp,
    required this.maxHp,
    required this.ataque,
    required this.simbolo,
    required this.descricao,
  });

  String descreverAcao() => '$nome se prepara para atacar.';

  @override
  String toString() => '[$simbolo] $nome  HP=$hp/$maxHp';
}

class Orc extends Inimigo {
  Orc()
      : super(
          nome: 'Orc',
          hp: 12,
          maxHp: 12,
          ataque: 5,
          simbolo: 'O',
          descricao: 'Um orc musculoso com fome de batalha.',
        );

  @override
  String descreverAcao() => 'O orc rosna e levanta sua clava!';
}

void main() {
  var orc = Orc();
  print(orc);                      // [O] Orc  HP=12/12
  print(orc.descricao);            // Um orc musculoso com fome de batalha.
  print(orc.descreverAcao());      // O orc rosna e levanta sua clava!
}

A escolha de não usar abstract em Inimigo é deliberada: o desafio pede subclasse, mas o Inimigo genérico (esqueleto básico) ainda pode ser instanciado. Quando o cap. 11 introduzir mixins (Combatente), o método base descreverAcao() vira gancho idiomático para subclasses customizarem. Variação útil: dar ao Orc um campo extra ferocidade: int que afeta o dano - aí o construtor cresce, mas o padrão se mantém.

Desafio 10.2 - Popule o mundo com Orcs

Mude a cripta no MundoTexto para ter um Orc em vez de Esqueleto. Verifique se o símbolo 'O' aparece corretamente. Adicione também um Orc em outra sala, por exemplo a caverna.

Solução de referência. A troca em si é uma linha; o ganho conceitual é o que importa - Sala aceita qualquer Inimigo, e subclasses entram sem mudanças no resto do código. Isso é polimorfismo na prática: a função entrarNaSala chama inimigo.descreverAcao() sem se importar se é Orc, Esqueleto ou Goblin. Cada subclasse responde do próprio jeito.

class Sala {
  final String nome;
  final String descricao;
  final Map<String, String> saidas;
  Inimigo? inimigoPresente;

  Sala({
    required this.nome,
    required this.descricao,
    Map<String, String>? saidas,
    this.inimigoPresente,
  }) : saidas = saidas ?? {};
}

void main() {
  var mundo = MundoTexto();

  mundo.adicionarSala('cripta', Sala(
    nome: 'Cripta Antiga',
    descricao: 'Ossos espalhados pelo chão. Algo se move.',
    saidas: {'oeste': 'corredor'},
    inimigoPresente: Orc(),  // antes era Esqueleto()
  ));

  mundo.adicionarSala('caverna', Sala(
    nome: 'Caverna Úmida',
    descricao: 'Água pinga das estalactites. Sombras grandes.',
    saidas: {'leste': 'corredor'},
    inimigoPresente: Orc(),
  ));

  // ao entrar:
  var cripta = mundo.obterSala('cripta')!;
  print(cripta.descricao);
  if (cripta.inimigoPresente != null) {
    print(cripta.inimigoPresente);              // [O] Orc  HP=12/12
    print(cripta.inimigoPresente!.descreverAcao());
  }
}

Note que o campo inimigoPresente é Inimigo? (anulável) - salas sem inimigo são a regra, com inimigo a exceção. O null no construtor padrão deixa criar salas “limpas” sem boilerplate. Em variações com mais de um inimigo por sala (List<Inimigo> inimigos), o padrão muda para “lista vazia em vez de null”, o que é mais ergonômico para iterar. Vale para o cap. 14 (combate em grupo).

Desafio 10.3 - Método em MundoTexto (Listar todos)

Escreva um método List<Inimigo> todosOsInimigos() em MundoTexto que devolve uma lista com todos os inimigos das salas (filtrando nulos). Teste imprimindo um relatório de todos os inimigos encontrados, mostrando nome, tipo e HP.

Solução de referência. A função demonstra um padrão útil: agregar dados que estão espalhados pelo mundo. Em Dart, isso vira uma cadeia limpa - values.map(...).whereType<Inimigo>().toList(). O whereType<T> filtra por tipo (descarta nulos) e refina o tipo estaticamente, evitando casts manuais. É a forma idiomática de “tira os nulos dessa lista”.

class MundoTexto {
  final Map<String, Sala> _salas = {};

  // ... métodos do desafio 8.5 ...

  List<Inimigo> todosOsInimigos() {
    return _salas.values
        .map((sala) => sala.inimigoPresente)
        .whereType<Inimigo>()
        .toList();
  }
}

void main() {
  var mundo = MundoTexto();
  // ... popula com cripta (Orc), caverna (Orc), tumba (Esqueleto) ...

  var inimigos = mundo.todosOsInimigos();
  print('=== RELATÓRIO DE INIMIGOS ===');
  print('Total: ${inimigos.length}');
  for (var i in inimigos) {
    print('  - $i (${i.runtimeType})  -> "${i.descreverAcao()}"');
  }
}

Saída típica:

=== RELATÓRIO DE INIMIGOS ===
Total: 3
  - [O] Orc  HP=12/12 (Orc)  -> "O orc rosna e levanta sua clava!"
  - [O] Orc  HP=12/12 (Orc)  -> "O orc rosna e levanta sua clava!"
  - [E] Esqueleto  HP=8/8 (Esqueleto)  -> "O esqueleto avança rangendo ossos."

runtimeType mostra o tipo concreto - útil para debug. Em código de produção, evite condicionais sobre runtimeType para tomar decisão (use polimorfismo). Mas para relatórios e logs, é informação valiosa. Variação: criar Map<Type, int> para histograma (“Orcs: 4, Esqueletos: 2”), agrupando por i.runtimeType num groupListsBy do package collection.

Desafio 10.4 - Sala de Combate obrigatório

Crie uma classe SalaCombate extends Sala que força a derrota do inimigo antes de permitir sair. Adicione um método bool podeSair() que verifica se o inimigoPresente está vivo. O jogador pode executar ações normais, mas "sair" retorna erro se o inimigo não estiver derrotado.

Solução de referência. SalaCombate é um caso clássico de “subclasse que adiciona invariante”: uma Sala normal não impõe regras de saída; uma SalaCombate impõe. A herança aqui é útil porque permite que o jogo trate qualquer Sala polimorficamente para movimentação básica, mas chama podeSair() antes - que pode ser sobrescrito.

class Sala {
  final String nome;
  final String descricao;
  final Map<String, String> saidas;
  Inimigo? inimigoPresente;

  Sala({
    required this.nome,
    required this.descricao,
    Map<String, String>? saidas,
    this.inimigoPresente,
  }) : saidas = saidas ?? {};

  bool podeSair() => true;  // salas comuns sempre permitem
}

class SalaCombate extends Sala {
  SalaCombate({
    required super.nome,
    required super.descricao,
    super.saidas,
    required Inimigo inimigo,
  }) : super(inimigoPresente: inimigo);

  @override
  bool podeSair() {
    var i = inimigoPresente;
    return i == null || i.hp <= 0;
  }
}

bool tentarMover(Jogador j, String direcao, MundoTexto mundo) {
  var atual = j.salaAtual!;
  if (!atual.podeSair()) {
    print('Você não pode sair: ${atual.inimigoPresente?.nome} ainda está vivo.');
    return false;
  }
  // ... lógica de movimento do desafio 9.3
  return true;
}

A sintaxe super.nome (sem this.nome) é um atalho do Dart 2.17+ para encaminhar parâmetros para a superclasse - elimina o boilerplate super(nome: nome, descricao: descricao). O construtor de SalaCombate torna inimigo obrigatório (sem ?), o que faz sentido para o tipo: uma “sala de combate” sem inimigo é incoerente. Em variações, dá para adicionar int oroVitoria que premia o jogador quando o inimigo morre - bonificando salas especiais.

Desafio 10.5 - Hierarquia de três níveis (Avó e netos)

Crie uma classe BipedeInteligente extends Inimigo (sem abstract) que adiciona um campo inteligencia: int e um método String insulto(). Depois crie Zumbi e Orc estendendo BipedeInteligente e sobrescrevendo insulto() com mensagens diferentes. Teste a hierarquia: Zumbi → BipedeInteligente → Inimigo.

Solução de referência. Hierarquia de três níveis é onde herança começa a mostrar tanto sua força quanto seus problemas. A força: comportamento comum a “bípedes inteligentes” (insultar, planejar emboscadas) fica num lugar só. O problema: se amanhã você quiser um OrcMago que combina dois ramos da árvore, a herança simples não dá conta - aí entra composição (cap. 22). Por enquanto, três níveis são tranquilos.

class BipedeInteligente extends Inimigo {
  int inteligencia;

  BipedeInteligente({
    required super.nome,
    required super.hp,
    required super.maxHp,
    required super.ataque,
    required super.simbolo,
    required super.descricao,
    required this.inteligencia,
  });

  String insulto() => '$nome grunhe algo incompreensível.';
}

class Zumbi extends BipedeInteligente {
  Zumbi()
      : super(
          nome: 'Zumbi',
          hp: 8,
          maxHp: 8,
          ataque: 3,
          simbolo: 'Z',
          descricao: 'Carne podre se arrasta em sua direção.',
          inteligencia: 1,
        );

  @override
  String insulto() => 'O zumbi balbucia: "Caaaarne... humaaaaana..."';
}

class Orc extends BipedeInteligente {
  Orc()
      : super(
          nome: 'Orc',
          hp: 12,
          maxHp: 12,
          ataque: 5,
          simbolo: 'O',
          descricao: 'Um orc musculoso com fome de batalha.',
          inteligencia: 4,
        );

  @override
  String insulto() => 'O orc cospe no chão: "Humano fraco! Vai morrer mal!"';
}

void main() {
  var zumbi = Zumbi();
  var orc = Orc();

  print(zumbi.insulto());
  print(orc.insulto());

  // hierarquia: cada um é (também) os tipos pais
  print(zumbi is Zumbi);                // true
  print(zumbi is BipedeInteligente);    // true
  print(zumbi is Inimigo);              // true
}

O teste is no final demonstra que polimorfismo funciona para cima na hierarquia - um Zumbi “é” um Inimigo, então List<Inimigo> aceita zumbis e orcs juntos. Esse é o ganho prático: o MundoTexto.todosOsInimigos() do desafio 10.3 não precisa mudar para suportar a nova hierarquia. Variação: tornar BipedeInteligente abstrata (com abstract class) e exigir que cada subclasse implemente insulto() - aí o compilador pega esquecimentos em vez de cair no default genérico.

Boss Final 10.6 - Integrar combate ao game loop

Refatore o game loop do Capítulo 7 para usar a classe Jogador em vez de variáveis soltas. Depois, modifique as salas para conter inimigos (use Sala.inimigoPresente). Quando o jogador entrar numa sala com inimigo, mostre: “Um [Zumbi] está aqui! [Z] HP: 5/8”. Adicione o comando "atacar" que reduz o HP do inimigo e toca um turno do inimigo atacando de volta. Sem a classe Combate ainda; apenas lógica simples de turnos. Quando o inimigo morre, a sala fica segura.

Solução de referência. Esse é o boss que junta tudo até agora: classes, polimorfismo, encapsulamento, validação. O combate aqui é minimalista (sem iniciativa, sem fuga, sem itens) - de propósito. O cap. 14 vai expandir; esta versão prova que a refatoração não quebrou nada.

import 'dart:io';

void executarAtaque(Jogador jogador, Sala sala) {
  var inimigo = sala.inimigoPresente;
  if (inimigo == null) {
    print('Não há ninguém para atacar.');
    return;
  }

  // turno do jogador
  inimigo.hp -= jogador.ataque;
  print('Você ataca ${inimigo.nome}! Causa ${jogador.ataque} de dano.');

  if (inimigo.hp <= 0) {
    print('${inimigo.nome} foi derrotado!');
    sala.inimigoPresente = null;
    return;
  }

  // turno do inimigo
  print(inimigo.descreverAcao());
  jogador.hp -= inimigo.ataque;
  print('${inimigo.nome} causa ${inimigo.ataque} de dano em você.');

  if (jogador.hp <= 0) {
    print(telaGameOver(jogador.nome, jogador.turnos, jogador.ouro));
    exit(0);
  }
}

void entrarNaSala(Jogador jogador, Sala sala) {
  jogador.salaAtual = sala;
  print(sala.descrever());

  var inimigo = sala.inimigoPresente;
  if (inimigo != null) {
    print('Um [${inimigo.nome}] está aqui!');
    print(' $inimigo');
  }
}

void gameLoop(Jogador jogador, MundoTexto mundo) {
  while (jogador.hp > 0) {
    stdout.write('\n> ');
    var entrada = (stdin.readLineSync() ?? '').toLowerCase().trim();
    jogador.turnos++;

    if (entrada == 'atacar') {
      executarAtaque(jogador, jogador.salaAtual!);
    } else if (entrada.startsWith('ir ')) {
      var direcao = entrada.substring(3);
      tentarMover(jogador, direcao, mundo);
    } else if (entrada == 'sair') {
      break;
    } else {
      print('Não entendi.');
    }
  }
}

Note como o gameLoop é curto - quase todo o trabalho está nas classes. Esse é o objetivo da refatoração: o loop principal vira “leio comando, despacho para método de objeto”, e cada objeto sabe se virar. Em variações, troque if/else if por Map<String, Function> (preâmbulo do cap. 12 com enums + sealed classes). Quando o cap. 22 introduzir Strategy, esse despacho vira uma tabela polimórfica de Comandos, cada um com executar(jogador, mundo).


Capítulo 11 - Mixins: poderes compartilhados

Desafio 11.1 - Mixin Herbívoro

Crie um mixin Herbivoro com um método comer(String planta) que imprime “Comi uma $planta! Recuperei 3 HP.” e chama curar(3). Aplique-o a uma classe concreta Coelho que também herda de Inimigo with Combatente. Teste comendo uma maçã.

Solução de referência. Mixin é o jeito Dart de adicionar capacidade a uma classe sem usar herança. Para coelho, “ser inimigo” é a base; “ser combatente” é uma capacidade; “ser herbívoro” é outra. Misturar é mais flexível que tentar fazer uma hierarquia única - você pode ter um cavalo que é herbívoro mas não combatente, ou um lobo combatente carnívoro. Cada mixin é uma habilidade isolada.

mixin Combatente {
  int hp = 100;
  int maxHp = 100;

  void curar(int valor) {
    hp = (hp + valor).clamp(0, maxHp);
    print('${runtimeType} curou $valor HP. Agora $hp/$maxHp.');
  }

  void sofrerDano(int valor) {
    hp = (hp - valor).clamp(0, maxHp);
  }
}

mixin Herbivoro {
  // depende de curar(), que vem de Combatente
  void comer(String planta) {
    print('Comi uma $planta! Recuperei 3 HP.');
    (this as Combatente).curar(3);
  }
}

class Coelho extends Inimigo with Combatente, Herbivoro {
  Coelho()
      : super(
          nome: 'Coelho',
          hp: 5,
          maxHp: 5,
          ataque: 1,
          simbolo: 'c',
          descricao: 'Um coelho selvagem com olhos vermelhos.',
        );
}

void main() {
  var coelho = Coelho();
  coelho.hp = 2;          // simulando dano prévio
  coelho.comer('maçã');   // imprime e cura 3 HP
}

O (this as Combatente).curar(3) parece feio mas é necessário porque Herbivoro não declara dependência via on Combatente - o desafio 11.4 mostra a forma mais elegante. Em variações reais, vale começar com mixin Herbivoro on Combatente para o compilador validar que herbívoro só pode ser misturado em classes que já tenham Combatente. O class Coelho extends Inimigo with Combatente, Herbivoro lê quase como uma declaração de RPG: “coelho é um inimigo com habilidades de combate e alimentação”.

Desafio 11.2 - Aplicar Combatente ao Jogador (Integração)

Certifique-se de que a sua classe Jogador usa with Combatente. Teste sofrerDano() e mostrarBarraVida() no main. Verifique se a barra de vida funciona corretamente durante combate.

Solução de referência. A graça aqui é perceber que Jogador e Inimigo agora compartilham comportamento via mixin, sem herança comum entre eles. Os dois “são combatentes” no sentido de “implementam essa interface”, não no sentido de “descendem do mesmo ancestral”. Esse desacoplamento é o que mixins entregam de mais valioso.

mixin Combatente {
  int hp = 100;
  int maxHp = 100;

  void sofrerDano(int valor) {
    hp = (hp - valor).clamp(0, maxHp);
  }

  void curar(int valor) {
    hp = (hp + valor).clamp(0, maxHp);
  }

  void mostrarBarraVida({int largura = 20}) {
    var razao = maxHp == 0 ? 0.0 : hp / maxHp;
    var preenchidas = (razao * largura).round();
    var barra = '█' * preenchidas + '░' * (largura - preenchidas);
    print('  HP: [$barra] $hp/$maxHp');
  }
}

class Jogador with Combatente {
  String nome;
  int ataque;
  int ouro;
  int turnos = 0;
  Sala? salaAtual;

  Jogador(this.nome, {int hp = 100, this.ataque = 5, this.ouro = 0}) {
    this.hp = hp;
    maxHp = hp;
  }
}

void main() {
  var aldric = Jogador('Aldric');
  aldric.mostrarBarraVida();  // HP: [████████████████████] 100/100

  aldric.sofrerDano(35);
  aldric.mostrarBarraVida();  // HP: [█████████████░░░░░░░] 65/100

  aldric.curar(20);
  aldric.mostrarBarraVida();  // HP: [█████████████████░░░] 85/100
}

Note que Jogador with Combatente (sem extends) - Jogador não herda de nada, só mistura Combatente. Isso é diferente de Inimigo que usava with Combatente em cima da herança. Os dois caminhos são válidos; a decisão é semântica. Variação: criar mixin Curavel separado (curar/morrer) e Atacavel separado (sofrer dano), aí você compõe Jogador with Curavel, Atacavel. Granularidade fina vira útil quando aparecem entidades parciais - boneco de treino só sofre dano, fonte mágica só cura.

Desafio 11.3 - Mixin Voador

Crie um mixin Voador com bool estaNoAr = false e métodos voar() (coloca estaNoAr = true), pousar() (coloca false). Crie uma classe Dragao extends Inimigo with Combatente, Voador. O dragão pode voar enquanto está em combate (aumentando sua defesa?).

Solução de referência. Voador é um mixin com estado próprio (estaNoAr). Mixins podem ter campos, métodos, getters - tudo. O detalhe arquitetural é que o estado fica visível para a classe-alvo: dragao.estaNoAr é acessível diretamente. Para o efeito “voar reduz dano sofrido”, interceptar sofrerDano da classe pai com @override faz o mixin “decorar” o comportamento existente.

mixin Voador {
  bool estaNoAr = false;

  void voar() {
    if (estaNoAr) {
      print('${runtimeType} já está no ar.');
      return;
    }
    estaNoAr = true;
    print('${runtimeType} bate as asas e ganha altura!');
  }

  void pousar() {
    if (!estaNoAr) return;
    estaNoAr = false;
    print('${runtimeType} pousa no chão.');
  }
}

class Dragao extends Inimigo with Combatente, Voador {
  Dragao()
      : super(
          nome: 'Dragão Vermelho',
          hp: 80,
          maxHp: 80,
          ataque: 25,
          simbolo: 'D',
          descricao: 'Escamas como brasas. O calor dele incomoda à distância.',
        );

  // sobrescreve sofrerDano para metade do dano enquanto no ar
  @override
  void sofrerDano(int valor) {
    var efetivo = estaNoAr ? (valor / 2).round() : valor;
    super.sofrerDano(efetivo);
    if (estaNoAr && efetivo < valor) {
      print('Defesa aérea: dano reduzido para $efetivo.');
    }
  }
}

void main() {
  var d = Dragao();

  d.sofrerDano(40);              // sofre 40
  print('HP após dano em terra: ${d.hp}');

  d.voar();
  d.sofrerDano(40);              // sofre só 20
  print('HP após dano no ar: ${d.hp}');

  d.pousar();
}

A composição with Combatente, Voador é a ordem importante - mixins à direita “envolvem” os à esquerda. Aqui, Voador não interage com Combatente diretamente, mas o sofrerDano sobrescrito na classe Dragao é quem une os dois. Variação: criar mixin Voador on Combatente (próximo desafio) e mover sofrerDano decorado para dentro do mixin - aí toda classe que misturar Voador ganha defesa aérea de graça. É mais elegante e mais reutilizável.

Desafio 11.4 - Mixin restrito com on

Crie um mixin Regenerador on Combatente que tem um método regenerar() que cura 2 HP por turno. Aplique-o a Inimigo with Combatente, Regenerador. O inimigo deve regenerar 2 HP ao final de cada turno de combate.

Solução de referência. O on Combatente é a cláusula que diz “este mixin só pode ser usado em classes que já são Combatente”. Isso traz dois ganhos: (1) o compilador pega o erro de misturar Regenerador numa classe sem Combatente, e (2) dentro do mixin, curar está disponível direto, sem cast. É a forma idiomática de dependência entre mixins.

mixin Regenerador on Combatente {
  int regeneracaoPorTurno = 2;

  void regenerar() {
    if (hp <= 0) return;          // mortos não regeneram
    if (hp >= maxHp) return;      // cheio não precisa
    curar(regeneracaoPorTurno);
    print('${runtimeType} regenera $regeneracaoPorTurno HP. ($hp/$maxHp)');
  }
}

class Troll extends Inimigo with Combatente, Regenerador {
  Troll()
      : super(
          nome: 'Troll da Caverna',
          hp: 30,
          maxHp: 30,
          ataque: 8,
          simbolo: 'T',
          descricao: 'Pele esverdeada e cicatrizes que somem rápido demais.',
        );
}

// não compila: Esqueleto não é Combatente
// class Esqueleto extends Inimigo with Regenerador { ... }

void main() {
  var t = Troll();
  t.sofrerDano(20);
  print('HP: ${t.hp}/${t.maxHp}');  // 10/30

  for (var turno = 1; turno <= 5; turno++) {
    print('-- turno $turno --');
    t.regenerar();
  }
  // HP final: 20/30 (2 por turno, 5 turnos = +10)
}

A linha comentada (class Esqueleto extends Inimigo with Regenerador) é a parte didática: tente descomentar e veja o erro do compilador. Esse tipo de proteção em tempo de compilação evita bugs que apareceriam só em runtime (“método curar não definido em Esqueleto”). Variação: aplicar Regenerador no Jogador também (mas com regeneracaoPorTurno = 0 por padrão, ativado só após beber poção mágica) - o jogador ganha regeneração temporária como buff. Esse é o gérmen do sistema de buffs/debuffs do cap. 22.

Boss Final 11.5 - Múltiplos mixins e resolução de conflito

Crie dois mixins Lutador e Mago, ambos com métodos atacar() que retornam String. Depois crie uma classe Paladim extends Inimigo with Combatente, Lutador, Mago. Como Dart resolve o conflito? (O último mixin, Mago, ganha.) Teste implementando String atacar() em ambos e veja qual é chamado. Demonstre a ordem de resolução.

Solução de referência. Quando dois mixins definem o mesmo método, o mais à direita ganha - é a regra do “diamante linearizado” do Dart. Por isso a ordem em with A, B, C importa: C tem prioridade sobre B, que tem sobre A. O super.metodo() dentro de um mixin chama a próxima implementação na cadeia, permitindo encadeamento estilo decorator. Esse boss demonstra os dois mecanismos.

mixin Lutador {
  String atacar() => 'Golpe físico: 5 de dano em melê.';
}

mixin Mago {
  String atacar() => 'Bola de fogo arcana: 8 de dano à distância.';
}

class Paladim extends Inimigo with Combatente, Lutador, Mago {
  Paladim()
      : super(
          nome: 'Paladim',
          hp: 40,
          maxHp: 40,
          ataque: 7,
          simbolo: 'P',
          descricao: 'Cavaleiro abençoado, com fé e espada igualmente afiadas.',
        );
}

class PaladimGuerreiro extends Inimigo with Combatente, Mago, Lutador {
  // ordem invertida: Lutador ganha
  PaladimGuerreiro()
      : super(
          nome: 'Paladim Guerreiro',
          hp: 40, maxHp: 40, ataque: 7, simbolo: 'P',
          descricao: 'Mais espada do que feitiço.',
        );
}

void main() {
  print(Paladim().atacar());
  // -> Bola de fogo arcana: 8 de dano à distância.

  print(PaladimGuerreiro().atacar());
  // -> Golpe físico: 5 de dano em melê.
}

A linearização funciona assim: with Combatente, Lutador, Mago cria a cadeia conceitual Inimigo -> Combatente -> Lutador -> Mago -> Paladim. Quando você chama paladim.atacar(), o Dart sobe a cadeia procurando a primeira implementação e encontra Mago. Para combinar os dois ataques, é possível sobrescrever atacar() em Paladim e chamar super.atacar() (que pega Mago) mais alguma coisa adicional. Para chamar Lutador.atacar() especificamente, não há sintaxe direta - o jeito comum é renomear (atacarFisico() e atacarMagico() em mixins separados, sem conflito).

Em variações, vale brincar com a ordem para sentir o efeito. Tente também adicionar mixin Sagrado com atacar() => 'Luz divina: 6 de dano' e ver qual ganha em with Combatente, Sagrado, Lutador, Mago. Esse padrão de “decorador empilhável” é a base do sistema de combos do cap. 23.


Capítulo 12 - Enums e o parser de comandos

Desafio 12.1 - Estender o enum Direcao (Direções diagonais)

Adicione nordeste, noroeste, sudeste, sudoeste como novos membros ao enum. Cada um deve ter um símbolo apropriado ('↗', '↖', '↘', '↙') e id curto ('ne', 'nw', 'se', 'sw'). Atualize o método oposta() também.

Solução de referência. Enums modernos do Dart aceitam campos, construtores e métodos - são tipos completos, não só nomes. O segredo aqui é manter a tabela de “opostos” coerente quando o enum cresce: cada direção precisa ter seu par. Em vez de um switch gigante no oposta(), um Map<Direcao, Direcao> deixa a tabela legível e fácil de auditar.

enum Direcao {
  norte('n', '↑'),
  sul('s', '↓'),
  leste('l', '→'),
  oeste('o', '←'),
  nordeste('ne', '↗'),
  noroeste('nw', '↖'),
  sudeste('se', '↘'),
  sudoeste('sw', '↙');

  final String id;
  final String simbolo;

  const Direcao(this.id, this.simbolo);

  static const _opostas = {
    Direcao.norte: Direcao.sul,
    Direcao.sul: Direcao.norte,
    Direcao.leste: Direcao.oeste,
    Direcao.oeste: Direcao.leste,
    Direcao.nordeste: Direcao.sudoeste,
    Direcao.noroeste: Direcao.sudeste,
    Direcao.sudeste: Direcao.noroeste,
    Direcao.sudoeste: Direcao.nordeste,
  };

  Direcao oposta() => _opostas[this]!;

  static Direcao? porId(String id) {
    for (var d in Direcao.values) {
      if (d.id == id) return d;
    }
    return null;
  }
}

void main() {
  for (var d in Direcao.values) {
    print('${d.simbolo} ${d.name.padRight(10)} (${d.id})  oposta: ${d.oposta().name}');
  }
}

Direcao.values é uma lista gerada pelo compilador com todos os membros - útil para iterar, popular menus, listar saídas válidas. O porId é a função inversa de “string vinda do jogador para enum”. Em variações, dá para adicionar cima e baixo (escadas e poços) e atualizar o mapa de opostas. Cuidado para não esquecer pares: se você adiciona cima no enum mas não no mapa _opostas, o oposta() lança Null check operator used on a null value no primeiro uso. Para garantia em tempo de compilação, vale escrever um teste unitário que verifica Direcao.values.every((d) => d.oposta().oposta() == d).

Desafio 12.2 - Novo comando ComandoEquipar

Crie uma sealed subclass ComandoEquipar com um campo arma: String. Adicione-a ao parser quando o jogador escreve “equipar espada” ou “eq lança”. Teste que o parser extrai o nome da arma corretamente.

Solução de referência. sealed class no Dart 3 é o que permite ao compilador conhecer todas as variantes de um tipo - o resultado é switch exaustivo sem default, mais segurança e código mais limpo. ComandoEquipar carrega o argumento como campo final; o parser quebra a linha em prefixo + resto.

sealed class Comando {}

class ComandoMover extends Comando {
  final Direcao direcao;
  ComandoMover(this.direcao);
}

class ComandoAtacar extends Comando {}
class ComandoInventario extends Comando {}
class ComandoExaminar extends Comando {}

class ComandoEquipar extends Comando {
  final String arma;
  ComandoEquipar(this.arma);
}

class ComandoDesconhecido extends Comando {
  final String entrada;
  ComandoDesconhecido(this.entrada);
}

Comando analisarLinha(String entrada) {
  var limpa = entrada.toLowerCase().trim();

  // direções diretas: "norte", "n", "ne" etc.
  var dir = Direcao.porId(limpa);
  if (dir != null) return ComandoMover(dir);

  if (limpa == 'atacar' || limpa == 'a') return ComandoAtacar();
  if (limpa == 'inventario' || limpa == 'inv' || limpa == 'i') return ComandoInventario();
  if (limpa == 'examinar' || limpa == 'x') return ComandoExaminar();

  // equipar com argumento: "equipar espada" ou "eq lança"
  for (var prefixo in ['equipar ', 'eq ']) {
    if (limpa.startsWith(prefixo)) {
      var arma = limpa.substring(prefixo.length).trim();
      if (arma.isNotEmpty) return ComandoEquipar(arma);
    }
  }

  return ComandoDesconhecido(entrada);
}

void main() {
  var c1 = analisarLinha('equipar espada');
  var c2 = analisarLinha('eq lança');
  print((c1 as ComandoEquipar).arma);  // espada
  print((c2 as ComandoEquipar).arma);  // lança
}

A varredura por lista de prefixos (['equipar ', 'eq ']) é mais limpa que dois if paralelos - adicionar uma nova abreviação é uma linha. O ponto sutil é o espaço no fim de cada prefixo: 'equipar'.startsWith('eq') daria match também para 'equilibrar'. Variação: aceitar mais de uma palavra na arma (equipar espada longa) - já funciona porque substring(prefixo.length).trim() captura “espada longa” inteiro.

Desafio 12.3 - Sinonímia no parser (Abreviações)

Adicione abreviações para direções: "u" (up) para norte, "d" (down) para sul, "l" para leste, "o" para oeste. Teste que analisarLinha("u") retorna ComandoMover(Direcao.norte) e analisarLinha("inv") retorna ComandoInventario().

Solução de referência. Aliases de comando são qualidade de vida: quem joga rápido usa abreviações, quem prefere clareza usa palavras inteiras. O ponto técnico é não duplicar a lógica - uma tabela Map<String, Direcao> resolve direção; outra tabela genérica resolve comandos sem argumento.

const aliasesDirecao = {
  'u': Direcao.norte,   'up': Direcao.norte,
  'd': Direcao.sul,     'down': Direcao.sul,
  'l': Direcao.leste,
  'o': Direcao.oeste,
};

Comando analisarLinha(String entrada) {
  var limpa = entrada.toLowerCase().trim();

  // 1) tenta alias direto de direção
  var aliasDir = aliasesDirecao[limpa];
  if (aliasDir != null) return ComandoMover(aliasDir);

  // 2) tenta id oficial da direção ('n', 'ne', etc.)
  var dirId = Direcao.porId(limpa);
  if (dirId != null) return ComandoMover(dirId);

  // 3) comandos sem argumento (com aliases)
  const comandosSimples = <String, Comando Function()>{
    'atacar': ComandoAtacar.new, 'a': ComandoAtacar.new,
    'inventario': ComandoInventario.new,
    'inv': ComandoInventario.new, 'i': ComandoInventario.new,
    'examinar': ComandoExaminar.new, 'x': ComandoExaminar.new,
  };
  var ctor = comandosSimples[limpa];
  if (ctor != null) return ctor();

  // 4) comandos com prefixo (equipar)
  for (var prefixo in ['equipar ', 'eq ']) {
    if (limpa.startsWith(prefixo)) {
      var arma = limpa.substring(prefixo.length).trim();
      if (arma.isNotEmpty) return ComandoEquipar(arma);
    }
  }

  return ComandoDesconhecido(entrada);
}

void main() {
  print(analisarLinha('u') is ComandoMover);     // true
  print(analisarLinha('inv') is ComandoInventario);  // true
  print((analisarLinha('u') as ComandoMover).direcao == Direcao.norte);  // true
}

ComandoAtacar.new é referência ao construtor padrão da classe, equivalente a () => ComandoAtacar(). Esse atalho do Dart 2.15+ torna a tabela comandosSimples bem mais legível que () => ComandoAtacar(). A pequena armadilha: l mapeia para leste; o “up” inglês (u) mapeia para norte. Se o jogo for bilíngue, evite colisões - l em português vs l em inglês para “left”/“oeste”. Solução: aliases separados por idioma, escolhidos por configuração.

Desafio 12.4 - Sugestão de comando semelhante

Quando o jogador escreve um comando desconhecido, em vez de apenas retornar ComandoDesconhecido(entrada), verifique se é similar a um comando válido (ex.: “atlcar” ≈ “atacar”) e sugira: “Você quis dizer ‘atacar’? Tente novamente.”

Solução de referência. Similaridade entre strings é o mundo da distância de Levenshtein (quantas edições - inserir, remover, trocar - para virar uma palavra na outra). Para comandos curtos, distância 1 ou 2 já cobre 90% dos erros de digitação. A implementação manual é didática; em projetos sérios, use o package string_similarity.

int levenshtein(String a, String b) {
  if (a == b) return 0;
  if (a.isEmpty) return b.length;
  if (b.isEmpty) return a.length;

  var prev = List<int>.generate(b.length + 1, (i) => i);
  var curr = List<int>.filled(b.length + 1, 0);

  for (var i = 1; i <= a.length; i++) {
    curr[0] = i;
    for (var j = 1; j <= b.length; j++) {
      var custo = a[i - 1] == b[j - 1] ? 0 : 1;
      curr[j] = [
        prev[j] + 1,        // deletar
        curr[j - 1] + 1,    // inserir
        prev[j - 1] + custo // trocar
      ].reduce((x, y) => x < y ? x : y);
    }
    var temp = prev; prev = curr; curr = temp;
  }
  return prev[b.length];
}

const comandosConhecidos = [
  'atacar', 'inventario', 'examinar', 'norte', 'sul', 'leste', 'oeste',
  'equipar', 'falar', 'sair',
];

String? sugerir(String entrada, {int distanciaMaxima = 2}) {
  var melhor = entrada;
  var dist = distanciaMaxima + 1;
  for (var c in comandosConhecidos) {
    var d = levenshtein(entrada, c);
    if (d < dist) {
      dist = d;
      melhor = c;
    }
  }
  return dist <= distanciaMaxima ? melhor : null;
}

void tratarDesconhecido(String entrada) {
  var sugerido = sugerir(entrada);
  if (sugerido != null) {
    print('Não conheço "$entrada". Você quis dizer "$sugerido"?');
  } else {
    print('Não conheço "$entrada". Digite "ajuda" para ver os comandos.');
  }
}

void main() {
  tratarDesconhecido('atlcar');    // sugere "atacar"
  tratarDesconhecido('invntario'); // sugere "inventario"
  tratarDesconhecido('xyz123');    // não sugere nada
}

A implementação clássica de Levenshtein usa matriz (a.length+1) x (b.length+1). A versão acima usa só duas linhas (prev e curr), economizando memória - um detalhe que importa para strings longas, irrelevante para comandos curtos mas educativo. Variação: privilegiar sugestões que começam com a mesma letra (peso menor para troca no primeiro caractere) ou usar trigramas - métricas mais sofisticadas, mesma arquitetura. Para o livro, distância 2 cobre os casos comuns sem sugerir bobagens.

Boss Final 12.5 - Comando ComandoFala (Fala com argumento)

Crie ComandoFala que aceita uma frase inteira (ex.: falar "Olá, mundo!"). Modifique o parser para capturar tudo após “falar” como argumento único (pode incluir múltiplas palavras e pontuação). Demonstre com uma frase completa.

Solução de referência. Capturar “tudo após o verbo” é mais simples que parsear aspas - basta substring(prefixo.length).trim(). Aspas opcionais ficam tratadas com um if que remove o par externo. A vantagem dessa abordagem permissiva: o jogador pode ou não usar aspas, e o jogo funciona dos dois jeitos.

class ComandoFala extends Comando {
  final String frase;
  ComandoFala(this.frase);
}

String _removerAspas(String s) {
  if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
    return s.substring(1, s.length - 1);
  }
  if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
    return s.substring(1, s.length - 1);
  }
  return s;
}

Comando analisarLinha(String entrada) {
  // mantém maiúsculas/minúsculas da fala intacta
  var trimada = entrada.trim();
  var limpa = trimada.toLowerCase();

  for (var prefixo in ['falar ', 'dizer ', 'f ']) {
    if (limpa.startsWith(prefixo)) {
      var frase = trimada.substring(prefixo.length).trim();
      frase = _removerAspas(frase);
      if (frase.isNotEmpty) return ComandoFala(frase);
    }
  }

  // ... resto do parser dos desafios anteriores ...
  return ComandoDesconhecido(entrada);
}

// no game loop:
void executar(Comando c, Jogador jogador) {
  switch (c) {
    case ComandoFala(:final frase):
      print('${jogador.nome}: "$frase"');
      // o jogo pode reagir a frases específicas:
      if (frase.toLowerCase().contains('abrir')) {
        print('Algo distante ressoa como um eco.');
      }
    // ... outros casos
    case _:
      print('Comando não tratado.');
  }
}

void main() {
  var c1 = analisarLinha('falar "Olá, mundo!"');
  var c2 = analisarLinha('dizer abram os portões');
  print((c1 as ComandoFala).frase);  // Olá, mundo!
  print((c2 as ComandoFala).frase);  // abram os portões

  executar(c1, Jogador('Aldric'));
  executar(c2, Jogador('Aldric'));
}

O padrão case ComandoFala(:final frase) é destructuring de Dart 3 - extrai o campo frase na variável local de mesmo nome. Mais limpo que (c as ComandoFala).frase, e o compilador valida o tipo. A chamada entrada.trim() antes de toLowerCase() é o detalhe que preserva a fala com maiúsculas originais (“Olá” continua com Ó maiúsculo, não vira “olá”). Variação: parsing real de aspas com escape (falar "ela disse \"oi\" e saiu") é trabalho de tokenizer, e em geral não vale para joguinho de texto - basta proibir aspas dentro de aspas ou usar delimitador alternativo.


Capítulo 13 - Ouro, Armas e Inventário

Desafio 13.1 - Item Consumível (Poções)

Crie uma classe Consumivel extends Item com um atributo efeito (string descrevendo o efeito, ex: “Cura 30 HP”) e hpRecuperado: int. Implemente na classe Jogador um método usarConsumivel(int indice) que remove o item do inventário, aplica o efeito (chama curar(hpRecuperado)), e mostra a mensagem de efeito.

Solução de referência. Consumivel é o primeiro item com comportamento próprio - até agora, itens eram dados puros. A subclasse herda nome/descrição/peso e adiciona efeito. O método usarConsumivel no Jogador encapsula o ciclo “remove + aplica” para que o resto do código nunca precise se preocupar em esquecer o remove. Quando o cap. 22 introduzir o padrão Command, esse ciclo vira EfeitoCura.aplicar(jogador).

class Consumivel extends Item {
  final String efeito;
  final int hpRecuperado;

  Consumivel({
    required String nome,
    required String descricao,
    required int peso,
    required this.efeito,
    required this.hpRecuperado,
  }) : super(nome, descricao, peso);
}

class Jogador with Combatente {
  String nome;
  int ataque;
  int ouro;
  final List<Item> inventario = [];

  Jogador(this.nome, {int hp = 100, this.ataque = 5, this.ouro = 0}) {
    this.hp = hp;
    maxHp = hp;
  }

  bool usarConsumivel(int indice) {
    if (indice < 0 || indice >= inventario.length) {
      print('Índice inválido.');
      return false;
    }
    var item = inventario[indice];
    if (item is! Consumivel) {
      print('${item.nome} não pode ser usado.');
      return false;
    }
    inventario.removeAt(indice);
    curar(item.hpRecuperado);
    print('Você usa ${item.nome}. ${item.efeito}.');
    return true;
  }
}

void main() {
  var aldric = Jogador('Aldric', hp: 100);
  aldric.sofrerDano(60);
  print('HP: ${aldric.hp}');  // 40

  aldric.inventario.add(Consumivel(
    nome: 'Poção Média',
    descricao: 'Líquido vermelho cintilante.',
    peso: 150,
    efeito: 'Cura 30 HP',
    hpRecuperado: 30,
  ));

  aldric.usarConsumivel(0);
  print('HP: ${aldric.hp}');  // 70
}

item is! Consumivel é o jeito null-safe de testar “é tudo menos esse tipo”. O Dart promove item para Consumivel automaticamente após o is, então item.hpRecuperado funciona sem cast. Em variações com mais tipos consumíveis (poção de mana, antídoto, escudo temporário), o método cresce; aí vale extrair para interface Usavel com método aplicar(Jogador) - polimorfismo elimina o if de tipo.

Desafio 13.2 - Limite de peso realista

Adicione um getter pesoTotalInventario ao Jogador que calcula o peso total. Implemente um limite de 5000 gramas total. O método tentarEquipar(Arma a) deve verificar se a mochila não ficará muito pesada. Se exceder, mostre: “Sua mochila está muito pesada! Largue algo antes de equipar.”

Solução de referência. Esse desafio é uma extensão direta do 8.2 - mesmo cálculo, agora aplicado em mais um contexto (equipar). Centralizar o pesoTotalInventario num getter evita duplicação. A diferença para equipar é que a arma equipada continua “pesando” - se você troca, soma a nova menos a antiga.

class Arma extends Item {
  final int dano;
  Arma({
    required String nome,
    required String descricao,
    required int peso,
    required this.dano,
  }) : super(nome, descricao, peso);
}

class Jogador with Combatente {
  // ... outros campos
  static const pesoMaximo = 5000;
  Arma? armaEquipada;

  int get pesoTotalInventario {
    var soma = inventario.fold(0, (acc, item) => acc + item.peso);
    if (armaEquipada != null) soma += armaEquipada!.peso;
    return soma;
  }

  bool tentarEquipar(Arma nova) {
    // peso final = inventário + (nova - antiga)
    var pesoAntiga = armaEquipada?.peso ?? 0;
    var pesoNovo = pesoTotalInventario - pesoAntiga + nova.peso;

    if (pesoNovo > pesoMaximo) {
      print('Sua mochila está muito pesada! '
            'Total ficaria em ${pesoNovo}g (limite ${pesoMaximo}g).');
      print('Largue algo antes de equipar.');
      return false;
    }

    armaEquipada = nova;
    print('${nova.nome} equipada. Carga: ${pesoTotalInventario}g/${pesoMaximo}g.');
    return true;
  }
}

A subtração - pesoAntiga é o detalhe crítico: trocar de arma não acumula peso. Sem ela, você equipa uma espada de 1kg, depois um machado de 2kg, e de repente “pesa” 3kg quando deveria pesar 2. O armaEquipada?.peso ?? 0 cobre o caso “primeira arma equipada”. Variação: armas pesadas dar bônus de dano e penalidade de movimento (Jogador.velocidade = 10 - peso ~/ 500), criando trade-off interessante.

Desafio 13.3 - Comparador de itens

Crie um método String compararItens(int indice1, int indice2) na classe Jogador que recebe dois índices e retorna uma string comparando: qual tem mais dano/defesa/efeito? Útil para o jogador decidir qual equipar.

Solução de referência. Comparador formata diferenças entre dois itens de forma legível. O desafio é cobrir tipos diferentes (Arma vs Consumível) sem virar uma espaguete de if (a is Arma && b is Arma). Solução: cada tipo de item retorna seus “stats relevantes” via método polimórfico, e o comparador apenas diff entre os dois mapas.

abstract class Item {
  final String nome;
  final String descricao;
  final int peso;
  Item(this.nome, this.descricao, this.peso);

  Map<String, int> stats() => {'peso': peso};
}

class Arma extends Item {
  final int dano;
  Arma({required String nome, required String descricao, required int peso, required this.dano})
      : super(nome, descricao, peso);

  @override
  Map<String, int> stats() => {'peso': peso, 'dano': dano};
}

class Consumivel extends Item {
  final int hpRecuperado;
  Consumivel({required String nome, required String descricao, required int peso, required this.hpRecuperado})
      : super(nome, descricao, peso);

  @override
  Map<String, int> stats() => {'peso': peso, 'cura_HP': hpRecuperado};
}

String compararItens(Item a, Item b) {
  var sb = StringBuffer();
  sb.writeln('${a.nome} vs ${b.nome}');

  var chaves = {...a.stats().keys, ...b.stats().keys};
  for (var k in chaves) {
    var va = a.stats()[k] ?? 0;
    var vb = b.stats()[k] ?? 0;
    var seta = va > vb ? '<' : (va < vb ? '>' : '=');
    sb.writeln('  $k: $va $seta $vb');
  }
  return sb.toString();
}

void main() {
  var espada = Arma(nome: 'Espada Curta', descricao: '', peso: 500, dano: 10);
  var machado = Arma(nome: 'Machado', descricao: '', peso: 1200, dano: 18);
  print(compararItens(espada, machado));
}

Saída:

Espada Curta vs Machado
  peso: 500 < 1200
  dano: 10 < 18

O < indica “menor à esquerda” (o operando da esquerda perde nessa stat). Para item com stat ausente do outro (Arma não tem cura_HP), o ?? 0 evita null. Variação: marcar com + ou - (“dano +8 para Machado”) fica mais intuitivo para quem não programa. A escolha entre < ou +/- é estética, o esqueleto funcional é o mesmo.

Desafio 13.4 - Venda em massa

Implemente um método int venderTodosDoTipo<T extends Item>() que vende todos os itens de tipo T (exceto equipados) e retorna o ouro total obtido. Por exemplo, venderTodosDoTipo<Consumivel>() vende todas as poções de uma vez.

Solução de referência. Generics (<T extends Item>) permite filtrar por tipo em tempo de execução com whereType<T>(). O extends Item é o limite superior do tipo - garante que T é sempre um Item, mesmo que específico. O preço de venda é uma fração do “valor” do item (definido por subclasse) - vou usar 30% do peso em ouro como heurística simples; em jogos reais, cada item tem um campo valor.

extension VendaJogador on Jogador {
  int _valorVenda(Item i) {
    // heurística: 30% do peso em ouro, com piso de 1
    var bruto = (i.peso * 0.3).round();
    return bruto < 1 ? 1 : bruto;
  }

  int venderTodosDoTipo<T extends Item>() {
    var alvos = inventario.whereType<T>().toList();
    var total = 0;
    for (var item in alvos) {
      // não vender o equipado
      if (item == armaEquipada) continue;
      total += _valorVenda(item);
      inventario.remove(item);
    }
    ouro += total;
    print('Vendido ${alvos.length} ${T.toString()}(s) por $total moedas.');
    return total;
  }
}

void main() {
  var aldric = Jogador('Aldric');
  aldric.inventario.addAll([
    Consumivel(nome: 'Poção Pequena', descricao: '', peso: 100, hpRecuperado: 10),
    Consumivel(nome: 'Poção Pequena', descricao: '', peso: 100, hpRecuperado: 10),
    Consumivel(nome: 'Poção Média',   descricao: '', peso: 150, hpRecuperado: 25),
    Arma(nome: 'Adaga', descricao: '', peso: 200, dano: 4),
  ]);

  aldric.venderTodosDoTipo<Consumivel>();
  print('Ouro: ${aldric.ouro}');
  print('Restam ${aldric.inventario.length} itens.');
}

extension VendaJogador on Jogador é o jeito de adicionar métodos a uma classe sem editar a classe original - útil quando “venda” é responsabilidade de outro módulo que importa Jogador. T.toString() na mensagem aproveita que generics em Dart retêm o tipo em runtime (diferente de Java); a mensagem mostra “Vendido 3 Consumivel(s)”. Variação: aceitar parâmetro bool para confirmar antes (venderTodosDoTipo<Consumivel>(confirmar: true) lista itens e pede ok). Importante na loja: vender por engano coisas raras é frustrante.

Boss Final 13.5 - Sistema de Loja

Crie uma classe Loja com um String nome, um List<Item> estoque, e um double taxaMarcup (ex: 1.2 para 20% mais caro). Implemente bool venderAoJogador(Jogador j, int indiceEstoque) que verifica ouro, cobra com markup, e adiciona ao inventário. Implemente bool comprarDoJogador(Jogador j, int indiceInventario) que compra a 50% do preço. Demonstre uma loja funcional.

Solução de referência. A loja é um agregador de regras de negócio - todas as validações de comércio moram aqui, fora do Jogador. O markup encarece compras (jogador paga mais que o “valor base”) e o 50% nas vendas (jogador recebe menos) é a fricção econômica clássica. Idealmente, cada item tem um campo precoBase; usando a heurística do desafio anterior (peso * 0.3) funciona como aproximação.

int precoBase(Item i) {
  var raw = (i.peso * 0.3).round();
  return raw < 1 ? 1 : raw;
}

class Loja {
  final String nome;
  final List<Item> estoque;
  final double taxaMarkup;  // 1.2 = 20% acima do preço base

  Loja({required this.nome, required this.estoque, this.taxaMarkup = 1.2});

  int precoVenda(Item i) => (precoBase(i) * taxaMarkup).round();
  int precoCompra(Item i) => (precoBase(i) * 0.5).round();

  void mostrarVitrine() {
    print('=== Loja: $nome ===');
    for (var i = 0; i < estoque.length; i++) {
      print('  [$i] ${estoque[i]}  -  ${precoVenda(estoque[i])} moedas');
    }
  }

  bool venderAoJogador(Jogador j, int indiceEstoque) {
    if (indiceEstoque < 0 || indiceEstoque >= estoque.length) return false;
    var item = estoque[indiceEstoque];
    var preco = precoVenda(item);

    if (j.ouro < preco) {
      print('Você não tem ouro suficiente. ${j.ouro} < $preco.');
      return false;
    }
    if (j.pesoTotalInventario + item.peso > Jogador.pesoMaximo) {
      print('Sua mochila não suporta o peso de ${item.nome}.');
      return false;
    }

    j.ouro -= preco;
    j.inventario.add(item);
    estoque.removeAt(indiceEstoque);
    print('Você compra ${item.nome} por $preco moedas. (Ouro: ${j.ouro})');
    return true;
  }

  bool comprarDoJogador(Jogador j, int indiceInventario) {
    if (indiceInventario < 0 || indiceInventario >= j.inventario.length) return false;
    var item = j.inventario[indiceInventario];
    if (item == j.armaEquipada) {
      print('Você não pode vender a arma equipada. Desequipe primeiro.');
      return false;
    }
    var preco = precoCompra(item);
    j.inventario.removeAt(indiceInventario);
    estoque.add(item);
    j.ouro += preco;
    print('Você vende ${item.nome} por $preco moedas. (Ouro: ${j.ouro})');
    return true;
  }
}

void main() {
  var loja = Loja(
    nome: 'Mercador Errante',
    estoque: [
      Consumivel(nome: 'Poção Média', descricao: '', peso: 150, hpRecuperado: 25),
      Arma(nome: 'Espada Longa', descricao: '', peso: 800, dano: 15),
      Consumivel(nome: 'Antídoto', descricao: '', peso: 80, hpRecuperado: 0),
    ],
  );

  var aldric = Jogador('Aldric')..ouro = 500;
  loja.mostrarVitrine();
  loja.venderAoJogador(aldric, 1);  // compra a espada
  loja.comprarDoJogador(aldric, 0); // vende o que está na primeira posição
}

Note como a Loja toma decisões e o Jogador apenas obedece (delegação clara de responsabilidade). Tudo o que envolve transação - peso, ouro, equipado - é validado num único lugar. Variação 1: estoque dinâmico (estoque rotaciona a cada visita) usando DateTime.now() ou número de turnos. Variação 2: reputação - quanto mais o jogador compra, melhor o preço (taxaMarkup -= 0.01 por compra, com piso em 1.0). Quando o cap. 18 introduzir persistência, a Loja é o tipo de entidade que vale salvar entre sessões (estoque, reputação acumulada).


Capítulo 14 - Combate por turnos

Desafio 14.1 - HUD em Combate com cores ANSI

Crie um método mostrarStatusCombate() que exibe HP em percentual e com código de cor: verde se acima de 75%, amarelo entre 50-75%, vermelho abaixo de 50%. Use escape codes ANSI: '\u001B[32m' verde, '\u001B[33m' amarelo, '\u001B[31m' vermelho, '\u001B[0m' reset.

Solução de referência. Códigos ANSI são strings curtas reconhecidas pelo terminal como instruções de formatação. \u001B[32m muda a cor para verde; \u001B[0m zera tudo (reset). O \u001B é o caractere ESC; sem ele, o terminal interpreta [32m como texto literal. A regra é sempre fechar com reset - se você esquecer, todo o output subsequente herda a cor, e a tela fica psicodélica.

class CoresAnsi {
  static const reset    = '\u001B[0m';
  static const verde    = '\u001B[32m';
  static const amarelo  = '\u001B[33m';
  static const vermelho = '\u001B[31m';
  static const negrito  = '\u001B[1m';
}

String corPorHp(int hp, int maxHp) {
  var pct = hp * 100 / maxHp;
  if (pct >= 75) return CoresAnsi.verde;
  if (pct >= 50) return CoresAnsi.amarelo;
  return CoresAnsi.vermelho;
}

extension HudCombate on Jogador {
  void mostrarStatusCombate() {
    var cor = corPorHp(hp, maxHp);
    var pct = (hp * 100 / maxHp).round();
    print('${cor}HP: $hp/$maxHp ($pct%)${CoresAnsi.reset}');
  }
}

void main() {
  var aldric = Jogador('Aldric', hp: 100);
  aldric.mostrarStatusCombate();  // verde
  aldric.sofrerDano(35);
  aldric.mostrarStatusCombate();  // amarelo (65%)
  aldric.sofrerDano(20);
  aldric.mostrarStatusCombate();  // vermelho (45%)
}

A constante CoresAnsi agrupa códigos num lugar único - se amanhã você quiser adicionar suporte a 256 cores ou desligar tudo em terminais que não suportam ANSI (Windows antigo, log em arquivo), troca a classe inteira sem caçar pelos arquivos. Detectar suporte é simples: stdout.supportsAnsiEscapes retorna true em terminais decentes. Variação: criar um wrapper cor(texto, cor) que retorna a string colorida e usar print(cor('PERIGO', CoresAnsi.vermelho)) no resto do código - mais conciso e centraliza o reset.

Desafio 14.2 - Ataque Crítico

Implemente crítico: 15% de chance de dano dobrado (x2). Use Random().nextDouble() < 0.15. Quando crítico ocorrer, registre no log: “GOLPE CRÍTICO! Dano dobrado!” e mostre o dano com destaque.

Solução de referência. Crítico é o sal do combate - sem isso, todo turno tem dano previsível e o jogo vira aritmética. Os 15% são frequentes o suficiente para serem felicidade comum, raros o suficiente para serem memoráveis. O destaque visual (negrito + cor) faz a diferença sensorial: o jogador percebe rapidamente “aconteceu algo bom”.

import 'dart:math';

final _rng = Random();

class ResultadoAtaque {
  final int dano;
  final bool critico;
  ResultadoAtaque(this.dano, this.critico);
}

ResultadoAtaque calcularDanoAtaque(int danoBase) {
  var critico = _rng.nextDouble() < 0.15;
  var dano = critico ? danoBase * 2 : danoBase;
  return ResultadoAtaque(dano, critico);
}

void executarAtaque(Jogador jogador, Inimigo inimigo) {
  var resultado = calcularDanoAtaque(jogador.ataque);

  if (resultado.critico) {
    print('${CoresAnsi.negrito}${CoresAnsi.amarelo}'
          '*** GOLPE CRÍTICO! Dano dobrado! ***'
          '${CoresAnsi.reset}');
  }

  inimigo.hp -= resultado.dano;
  print('Você causa ${resultado.dano} de dano em ${inimigo.nome}.');
}

void main() {
  // simulação para ver críticos
  var golpes = 100;
  var criticos = 0;
  for (var i = 0; i < golpes; i++) {
    if (calcularDanoAtaque(5).critico) criticos++;
  }
  print('Criticos em $golpes ataques: $criticos (esperado ~15)');
}

Devolver um ResultadoAtaque em vez de só o dano facilita testes e logging - o caller decide o que fazer com a informação “foi crítico”. Para sair do “global Random”, injete o Random por parâmetro (calcularDanoAtaque(int dano, {Random? rng})); aí em testes você passa um Random semeado e o resultado vira determinístico. Variação: arma com multiplicadorCritico próprio (Arma.multiplicador = 2.5 para “Espada do Caos”) expande crítico de “binário 1x ou 2x” para “spectro contínuo”.

Desafio 14.3 - Limite de turno (Fuga automática)

Adicione um limite: combate não pode durar mais de 10 turnos. Se chegar ao limite e ainda houver combate, o jogador é forçado a fugir automaticamente com mensagem: “A luta durou demais, você foge pela sua vida!”

Solução de referência. Limite de turno evita “stalemate” - jogador defendendo sem causar dano vs inimigo regenerando mais que apanha. Em vez de deixar o jogador travado, o jogo decide. Forçar fuga com penalidade (perder ouro, deixar item para trás) seria justo para o design, mas o desafio pede só a fuga.

class Combate {
  final Jogador jogador;
  final Inimigo inimigo;
  int turno = 0;
  static const limite = 10;

  Combate(this.jogador, this.inimigo);

  bool prossegue() {
    return jogador.hp > 0 && inimigo.hp > 0 && turno < limite;
  }

  void executar() {
    while (prossegue()) {
      turno++;
      print('\n-- Turno $turno --');
      executarAtaque(jogador, inimigo);
      if (inimigo.hp <= 0) break;
      executarAtaqueInimigo(inimigo, jogador);
    }

    if (jogador.hp <= 0) {
      print('Você foi derrotado.');
    } else if (inimigo.hp <= 0) {
      print('Você venceu ${inimigo.nome}!');
    } else if (turno >= limite) {
      print('A luta durou demais, você foge pela sua vida!');
      jogador.hp = (jogador.hp - 5).clamp(0, jogador.maxHp);
    }
  }
}

Encapsular o combate numa classe Combate em vez de função permite que o estado (turno, jogador, inimigo) viva junto, e o método prossegue() documenta as condições de fim em um lugar. Variação: limite proporcional à dificuldade (limite = 5 + inimigo.maxHp ~/ 20) para que bosses possam durar mais. Outra: dar ao jogador a escolha “fugir manualmente” antes do limite, com chance de sucesso baseada em HP relativo - design mais rico, mesma arquitetura.

Desafio 14.4 - Ação Defensa com Riposte

Implemente uma ação defender(): reduz dano sofrido em 50% neste turno. Além disso, ao sofrer ataque enquanto defendendo, há 30% de chance de ripostear (contra-ataque) com 30% do seu dano normal.

Solução de referência. Defender é a ação tática que diz “perco turno, mas reduzo dano”. Riposte adiciona um upside: às vezes o ataque inimigo se vira contra ele. Implementar precisa de uma flag transitória (estaDefendendo) que dura um turno só - importante zerar no início do próximo turno do jogador, senão a defesa “fica grudada”.

class Jogador with Combatente {
  bool estaDefendendo = false;
  int ataque = 5;
  String nome;
  Jogador(this.nome, {int hp = 100, this.ataque = 5}) { this.hp = hp; maxHp = hp; }

  void defender() {
    estaDefendendo = true;
    print('Você levanta o escudo, em postura defensiva.');
  }

  /// Retorna dano efetivamente recebido. Se houver riposte, devolve negativo.
  int sofrerAtaque(int danoBase) {
    var dano = estaDefendendo ? (danoBase / 2).round() : danoBase;
    sofrerDano(dano);
    if (estaDefendendo && _rng.nextDouble() < 0.30) {
      var riposte = (ataque * 0.3).round();
      print('Riposte! Você revida com $riposte de dano.');
      return -riposte;
    }
    return dano;
  }

  void iniciarTurno() {
    estaDefendendo = false;
  }
}

A convenção “sinal negativo do retorno = riposte” é simples mas pode confundir. Em código de produção, prefira retornar uma ResultadoCombate com campos explícitos: danoSofrido, danoRipostado, criticoSofrido. Cresce um pouco, mas o caller não precisa inferir intenção. Variação: armas pesadas reduzem chance de riposte (Arma.bonusRiposte = -0.10), criando trade-off entre dano e contra-ataque.

Desafio 14.5 - Combate em Grupo (Avançado)

Implemente combate contra múltiplos inimigos. Crie uma classe CombateGrupo que recebe List<Inimigo> inimigos e o jogador enfrenta todos sequencialmente, mas numa ordem que você escolhe (IA básica: mais fraco primeiro). Registre cada transição entre inimigos no log.

Solução de referência. Combate em grupo “sequencial” é a versão didática - jogador encara um por vez, na ordem que o jogo escolhe. Mais sofisticado seria ataque em paralelo (todos atacam por turno), mas isso explode em complexidade quando o jogador precisa escolher o alvo. Para o desafio, ordenar por HP crescente (mais fraco primeiro) é estratégia clássica de “remover ameaças rápido”.

class CombateGrupo {
  final Jogador jogador;
  final List<Inimigo> inimigos;

  CombateGrupo(this.jogador, this.inimigos);

  void executar() {
    var fila = [...inimigos]..sort((a, b) => a.hp.compareTo(b.hp));

    print('=== EMBOSCADA ===');
    print('Inimigos: ${fila.map((i) => "${i.nome}(${i.hp})").join(", ")}');

    for (var i = 0; i < fila.length; i++) {
      var inimigo = fila[i];
      print('\n>>> Enfrentando ${inimigo.nome} (${i + 1}/${fila.length})');
      Combate(jogador, inimigo).executar();

      if (jogador.hp <= 0) {
        print('Você foi derrotado pelo grupo.');
        return;
      }
    }
    print('\n=== GRUPO INTEIRO DERROTADO ===');
  }
}

[...inimigos]..sort(...) cria cópia da lista original e ordena - evita mutar a lista que veio de fora (princípio “fica como recebeu, devolve como recebeu”). Variação 1: IA “ameaça primeiro” - ordenar por ataque decrescente em vez de HP crescente. Variação 2: pausa entre lutas (stdin.readLineSync() para “continuar”) - jogador respira, decide se usa poção. Para o estilo “todos atacam de uma vez”, o cap. 22 introduz a fila de iniciativa.

Boss Final 14.6 - Poções dinâmicas (Integração com inventário)

Crie uma classe Pocao extends Item com um campo int curaHP e um método usar(Jogador j) que chama j.curar(curaHP). Refatore usarItem() no combate para checar o tipo de item: se for Pocao, chama pocao.usar(jogador). Crie três tipos: PocaoPequena (10 HP), PocaoMedia (25 HP), PocaoGrande (50 HP). Demonstre no combate.

Solução de referência. Poção com método usar é a transição de “item = dado” para “item = comportamento”. Cada subclasse encapsula o efeito; o combate só chama pocao.usar(jogador) sem se importar com qual tipo é. Esse é o padrão Strategy disfarçado de herança - cada poção é uma estratégia de cura.

class Pocao extends Item {
  final int curaHP;

  Pocao({
    required String nome,
    required String descricao,
    required int peso,
    required this.curaHP,
  }) : super(nome, descricao, peso);

  void usar(Jogador j) {
    print('Você bebe ${nome}. ($curaHP HP recuperados.)');
    j.curar(curaHP);
  }
}

class PocaoPequena extends Pocao {
  PocaoPequena()
      : super(nome: 'Poção Pequena', descricao: 'Frasco azul, sabor de menta.', peso: 100, curaHP: 10);
}
class PocaoMedia extends Pocao {
  PocaoMedia()
      : super(nome: 'Poção Média', descricao: 'Líquido vermelho cintilante.', peso: 150, curaHP: 25);
}
class PocaoGrande extends Pocao {
  PocaoGrande()
      : super(nome: 'Poção Grande', descricao: 'Frasco dourado, viscoso.', peso: 250, curaHP: 50);
}

extension UsoEmCombate on Jogador {
  bool usarItem(int indice) {
    if (indice < 0 || indice >= inventario.length) return false;
    var item = inventario[indice];
    if (item is Pocao) {
      item.usar(this);
      inventario.removeAt(indice);
      return true;
    }
    print('${item.nome} não pode ser usado em combate.');
    return false;
  }
}

O detalhe sutil de usar usarItem(1) em sequência: ao remover o item, os índices dos demais “shiftam”. UIs reais mostram inventário com índice + nome (“[1] Poção Grande”) em vez de “use 1” às cegas. Variação: poções com efeitos diferentes (PocaoForca, PocaoAgilidade) seguem o mesmo padrão - cada uma sobrescreve usar(). Quando o cap. 22 trouxer Strategy formal, esse padrão de “subclasse com método polimórfico” vira interface explícita (abstract class Item { void usar(Jogador j); }), eliminando o if (item is Pocao).


Parte III — A Masmorra Desperta

Capítulo 15 - Da Sala ao Tile: Pensando em 2D

Desafio 15.1 - O Corredor da Perdição (Mapa com segredos)

Crie um mapa 20x15 onde um corredor central horizontal liga uma entrada (esquerda) a uma saída (direita). Adicione duas pequenas salas laterais (uma acima, outra abaixo do corredor), cada uma com uma escada. Teste caminhando: consegue sair? Encontra as escadas? Use loops e lógica para desenhar, não hardcode cada tile.

Solução de referência. A regra de ouro do mapa procedural é: gere por regras, não por tiles literais. Hardcodar cada [0][0] = '#' funciona para 5x5; em 20x15 vira pesadelo. A solução é uma matriz inicializada com paredes e operações de “esculpir” o corredor e as salas. Cada operação é um bloco que zera tiles com chão.

enum Tile { parede, chao, escadaSobe, escadaDesce, entrada, saida }

class MapaMasmorra {
  final int largura;
  final int altura;
  late List<List<Tile>> grade;

  MapaMasmorra(this.largura, this.altura) {
    grade = List.generate(altura, (_) => List.filled(largura, Tile.parede));
  }

  void esculpirCorredor(int linhaY) {
    for (var x = 1; x < largura - 1; x++) {
      grade[linhaY][x] = Tile.chao;
    }
  }

  void esculpirSala(int x0, int y0, int w, int h) {
    for (var y = y0; y < y0 + h; y++) {
      for (var x = x0; x < x0 + w; x++) {
        if (x >= 0 && y >= 0 && x < largura && y < altura) {
          grade[y][x] = Tile.chao;
        }
      }
    }
  }
}

void main() {
  var mapa = MapaMasmorra(20, 15);

  // corredor central
  mapa.esculpirCorredor(7);

  // sala superior (Y=2..5)
  mapa.esculpirSala(4, 2, 4, 4);
  mapa.grade[2][5] = Tile.escadaSobe;
  mapa.grade[5][5] = Tile.chao;  // conexão com corredor (Y=6 fica parede)
  mapa.grade[6][5] = Tile.chao;

  // sala inferior (Y=9..13)
  mapa.esculpirSala(12, 9, 4, 4);
  mapa.grade[12][13] = Tile.escadaDesce;
  mapa.grade[8][13] = Tile.chao;
  mapa.grade[9][13] = Tile.chao;

  // entrada e saída no corredor
  mapa.grade[7][0] = Tile.entrada;
  mapa.grade[7][19] = Tile.saida;
}

A conexão entre sala lateral e corredor é o detalhe que separa “salas vazias soltas” de “labirinto navegável”. Sem essa coluna vertical de chão, o jogador caminha pelo corredor e vê as escadas mas não consegue chegar nelas. Variação: adicionar bool conectada(int x0, int y0) que faz BFS e valida que toda sala alcança a saída - útil quando geração ficar procedural de verdade (cap. 19). Para gerar salas com paredes “irregulares” (mais orgânico), aplique cellular automata em cima do retângulo - mas isso é território do cap. 28.

Desafio 15.2 - Paredes Atmosféricas (Visual)

Modifique tileParaChar() para renderizar diferentes símbolos para tipos de parede: para pedra sólida, para rachaduras, para umidade. Escolha pelo menos dois. Execute para comparar o visual. Qual versão transmite mais a sensação de masmorra antiga?

Solução de referência. A diferença entre (bloco cheio) e (cruz com barras) é enorme em ambiente CRT/terminal: o primeiro parece pedra maciça, o segundo parece pedra rachada. Subdividir paredes em tipos é o primeiro passo para terreno expressivo. Em vez de um único enum parede, criar variantes paredeFirme, paredeRachada, paredeUmida é mais rico para o futuro.

enum Tile {
  paredeFirme, paredeRachada, paredeUmida,
  chao, escadaSobe, escadaDesce, entrada, saida,
}

String tileParaChar(Tile t) {
  switch (t) {
    case Tile.paredeFirme:   return '█';
    case Tile.paredeRachada: return '╬';
    case Tile.paredeUmida:   return '∿';
    case Tile.chao:          return '.';
    case Tile.escadaSobe:    return '<';
    case Tile.escadaDesce:   return '>';
    case Tile.entrada:       return 'E';
    case Tile.saida:         return 'X';
  }
}

// distribui variantes aleatoriamente:
import 'dart:math';
final _rng = Random();

Tile paredeAleatoria() {
  var r = _rng.nextInt(100);
  if (r < 70) return Tile.paredeFirme;
  if (r < 90) return Tile.paredeRachada;
  return Tile.paredeUmida;
}

Com 70% firme, 20% rachada, 10% úmida, o mapa parece vivo sem virar caótico. Texturas demais distraem; menos é mais. Variação: agrupar variantes por região - cavernas têm mais umidade, ruínas mais rachaduras, calabouços recentes têm pedra firme. Isso conecta visual com narrativa. Para preservar o aspecto após ^L (clear screen), o mapa precisa ser determinístico (Random com seed) - senão cada redesenho muda as texturas e parece bug.

Desafio 15.3 - Teleportes mágicos (Dinâmica)

Adicione um novo tipo de tile teleporte que renderiza como . Quando o jogador pisa nele, é teletransportado para outra posição aleatória do mapa. Crie um mapa com 3-4 teleportes. Dica: use Random().nextInt(largura) e Random().nextInt(altura) para coordenadas aleatórias válidas (não em paredes).

Solução de referência. Teleporte é o primeiro tile com comportamento ativo - reage ao jogador pisar. A lógica vai no método mover() que detecta o tipo do tile destino. O “destino aleatório” precisa ser um tile chão; senão o jogador para grudado dentro de uma parede.

class MapaMasmorra {
  // ... como antes ...

  List<List<int>> _todosOsChaos() {
    var lista = <List<int>>[];
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        if (grade[y][x] == Tile.chao) lista.add([x, y]);
      }
    }
    return lista;
  }

  List<int> posicaoChaoAleatoria() {
    var chaos = _todosOsChaos();
    return chaos[_rng.nextInt(chaos.length)];
  }
}

void moverJogador(Jogador j, int dx, int dy, MapaMasmorra mapa) {
  var novoX = j.x + dx;
  var novoY = j.y + dy;

  if (novoX < 0 || novoY < 0 || novoX >= mapa.largura || novoY >= mapa.altura) return;
  var destino = mapa.grade[novoY][novoX];

  if (destino == Tile.paredeFirme || destino == Tile.paredeRachada) return;

  // chega no teleporte: pisa e em seguida é jogado pra outro lugar
  j.x = novoX;
  j.y = novoY;
  if (destino == Tile.teleporte) {
    var alvo = mapa.posicaoChaoAleatoria();
    j.x = alvo[0];
    j.y = alvo[1];
    print('Você sente o chão sumir e reaparece em outra parte da masmorra.');
  }
}

O _todosOsChaos() enumera todas as posições de chão; chamar uma vez no início e cachear é melhor se o mapa for grande, mas para 20x15 a varredura é gratuita. Cuidado para não escolher como destino o próprio teleporte (loop infinito ao pisar) - filtre com grade[y][x] == Tile.chao em vez de != Tile.parede*. Variação: teleportes pareados (dois portais que ligam mutuamente) em vez de aleatórios - mais previsível, mais estratégico. Implementa-se com Map<Posicao, Posicao> em MapaMasmorra.

Desafio 15.4 - Múltiplos andares (Profundidade)

Implemente andares: quando o jogador pisa em escadaDesce, um novo MapaMasmorra é gerado. Use List<MapaMasmorra> andares para rastreá-los. Mostre “Andar 3 de 10” na HUD. Cada andar mais profundo deveria ter mais inimigos (aumentar dificuldade). Use uma seed ligeiramente diferente para cada andar.

Solução de referência. Andares são instâncias separadas de MapaMasmorra em uma lista. A diferença entre escadaDesce e escadaSobe é a direção do índice. Subir indo para o andar anterior preserva o estado (inimigos mortos, itens pegos); descer cria novo se nunca esteve lá, recupera se já. A dificuldade escala com o andar via parâmetro passado ao gerador.

class Jogo {
  final List<MapaMasmorra> andares = [];
  int andarAtual = 0;
  Jogador jogador;

  Jogo(this.jogador) {
    andares.add(gerarAndar(1));
    posicionarJogadorEm(andares.first.entradaPosicao());
  }

  MapaMasmorra gerarAndar(int profundidade) {
    var seed = 42 + profundidade;
    var rng = Random(seed);
    var mapa = MapaMasmorra(20, 15);

    // mais inimigos quanto mais fundo
    var quantidadeInimigos = 2 + profundidade;
    for (var i = 0; i < quantidadeInimigos; i++) {
      var p = mapa.posicaoChaoAleatoriaCom(rng);
      mapa.inimigos.add(Orc()..x = p[0]..y = p[1]);
    }
    return mapa;
  }

  void descer() {
    andarAtual++;
    if (andarAtual >= andares.length) {
      andares.add(gerarAndar(andarAtual + 1));
    }
    posicionarJogadorEm(andares[andarAtual].entradaPosicao());
    print('Andar ${andarAtual + 1} de 10.');
  }

  void subir() {
    if (andarAtual == 0) return;
    andarAtual--;
    posicionarJogadorEm(andares[andarAtual].saidaPosicao());
  }
}

A seed = 42 + profundidade garante que cada andar é determinístico (volta o mesmo se você “descer e subir e descer”), mas diferente entre si. Para “rogue-like” puro, deixe sem seed - cada partida é única, morrer é definitivo. Variação: andares com tema (gelo = chão escorregadio aplica movimento extra; lava = dano ambiental) - faça enum TemaAndar { caverna, gelo, lava, biblioteca } e o gerador escolhe baseado no andar (1-3 caverna, 4-6 lava, 7-9 gelo, 10 biblioteca = boss).

Boss Final 15.5 - Campo de Visão com tocha (FOV simplificado)

Implemente campo de visão: cada tile tem um bool visivel. Inicialmente, renderize apenas tiles dentro de um raio 3 do jogador (distância Manhattan). Conforme caminha, novos tiles são marcados como explorados. Tiles não visíveis aparecem como (sombra). Isso simula uma tocha iluminando a escuridão. Ao pisar em novo tile, atualiza a visibilidade dinamicamente.

Solução de referência. FOV (Field of View) é o que diferencia “labirinto exposto” de “masmorra misteriosa”. O algoritmo mais simples é distância Manhattan (|dx| + |dy|) - circulo em formato losango, barato de calcular. Distinguir “atualmente visível” (no raio) de “já explorado” (visto algum dia) permite duas tonalidades: brilhante e nebuloso.

class MapaMasmorra {
  // ... como antes ...
  late List<List<bool>> visivel;   // está iluminado AGORA
  late List<List<bool>> descoberto; // já foi iluminado alguma vez

  MapaMasmorra(this.largura, this.altura) {
    grade = List.generate(altura, (_) => List.filled(largura, Tile.paredeFirme));
    visivel = List.generate(altura, (_) => List.filled(largura, false));
    descoberto = List.generate(altura, (_) => List.filled(largura, false));
  }

  void atualizarVisibilidade(int px, int py, {int raio = 3}) {
    // limpa visibilidade atual
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        visivel[y][x] = false;
      }
    }
    // marca tiles no raio
    for (var dy = -raio; dy <= raio; dy++) {
      for (var dx = -raio; dx <= raio; dx++) {
        if (dx.abs() + dy.abs() > raio) continue;
        var x = px + dx;
        var y = py + dy;
        if (x < 0 || y < 0 || x >= largura || y >= altura) continue;
        visivel[y][x] = true;
        descoberto[y][x] = true;
      }
    }
  }

  String renderizar(int px, int py) {
    var sb = StringBuffer();
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        if (x == px && y == py) { sb.write('@'); continue; }
        if (visivel[y][x]) {
          sb.write(tileParaChar(grade[y][x]));
        } else if (descoberto[y][x]) {
          sb.write('░');  // já viu, hoje não vê
        } else {
          sb.write(' ');  // nunca viu
        }
      }
      sb.writeln();
    }
    return sb.toString();
  }
}

A separação visivel vs descoberto é crucial. Sem descoberto, o jogador esquece tudo o que andou - frustrante. Sem visivel, vê tudo ao mesmo tempo - tira tensão. Os dois juntos criam a sensação clássica de tocha. Variação: paredes bloqueiam visão (raycasting) - o algoritmo Bresenham desenha linha do jogador para cada tile, parando ao encontrar parede; mais elegante mas custa O(raio²) testes de linha. Para mapas pequenos é viável; para 100x100 vale algoritmos especializados (Shadowcasting, Permissive FOV). O cap. 28 explora isso.


Capítulo 16 - TelaAscii: O Buffer de Renderização

Desafio 16.1 - Cores para o Caos (ANSI)

Adicione cores ANSI ao TelaAscii: \x1B[31m vermelho (inimigos, perigo), \x1B[32m verde (jogador, vida), \x1B[33m amarelo (ouro), \x1B[37m branco (paredes), \x1B[0m reset. Crie um método colorir(String char, String cor) que envolve o caractere. Renderize o mapa com cores: jogador verde, inimigos vermelhos, ouro amarelo, paredes brancas. Compare antes e depois visualmente.

Solução de referência. Cores transformam o terminal de planilha em cenário. A função colorir é um wrapper trivial - prefixa com o código, sufixa com reset. O ganho aparece quando você compõe: cada tile sabe sua cor “padrão”, e a função tileColorido aplica.

class TelaAscii {
  String colorir(String texto, String cor) {
    return '$cor$texto\u001B[0m';
  }

  String corPorTile(Tile t) {
    switch (t) {
      case Tile.paredeFirme:
      case Tile.paredeRachada:
      case Tile.paredeUmida:
        return '\u001B[37m';  // branco
      case Tile.chao:
        return '\u001B[90m';  // cinza escuro
      case Tile.escadaSobe:
      case Tile.escadaDesce:
        return '\u001B[36m';  // ciano
      default:
        return '\u001B[0m';
    }
  }

  void renderizar(MapaMasmorra mapa, Jogador j, List<Inimigo> inimigos) {
    for (var y = 0; y < mapa.altura; y++) {
      var sb = StringBuffer();
      for (var x = 0; x < mapa.largura; x++) {
        if (x == j.x && y == j.y) {
          sb.write(colorir('@', '\u001B[32m'));
        } else {
          var inim = inimigos.where((i) => i.x == x && i.y == y).firstOrNull;
          if (inim != null) {
            sb.write(colorir(inim.simbolo, '\u001B[31m'));
          } else {
            var t = mapa.grade[y][x];
            sb.write(colorir(tileParaChar(t), corPorTile(t)));
          }
        }
      }
      print(sb.toString());
    }
  }
}

Note como cada caractere recebe seu próprio par cor-reset: isso facilita debugging (se uma cor “vazar”, você sabe onde está faltando reset). Performance-wise, é um pouco caro (mais bytes saindo do terminal), mas para 20x15 = 300 caracteres por frame, irrelevante. Para mapas maiores, vale agrupar runs de mesma cor. Variação: ler “tema” de um arquivo de config (tema.yaml com parede: 37) para o jogador customizar a paleta sem recompilar - útil para acessibilidade (daltonismo).

Desafio 16.2 - HUD do Sobrevivente Expandida

Expanda a HUD para mostrar: (1) quantos inimigos visíveis, (2) quantos itens próximos (dentro de raio 3), (3) qual andar (ex: “Andar 5 de 10”), (4) efeitos ativos (se envenenado, maldito, etc). Organize como uma coluna de status estruturada. Use StringBuffer e cálculos em tempo real dos valores.

Solução de referência. A HUD do roguelike clássico fica na lateral - usa o espaço vertical do terminal sem competir com o mapa. Cálculos em tempo real (contar inimigos visíveis, itens no raio) vivem em métodos do mapa/jogador; a HUD só lê e formata. Separar “calcular” de “exibir” mantém o código testável.

class HudLateral {
  String renderizar({
    required Jogador jogador,
    required Jogo jogo,
    required MapaMasmorra mapaAtual,
  }) {
    var sb = StringBuffer();
    sb.writeln('╔══════════════╗');
    sb.writeln('║ ${jogador.nome.padRight(13)}║');
    sb.writeln('╠══════════════╣');
    sb.writeln('║ HP: ${jogador.hp}/${jogador.maxHp}'.padRight(15) + '║');
    sb.writeln('║ ATK: ${jogador.ataque}'.padRight(15) + '║');
    sb.writeln('║ \$: ${jogador.ouro}'.padRight(15) + '║');
    sb.writeln('║              ║');

    var inimigosVisiveis = mapaAtual.inimigos
        .where((i) => mapaAtual.visivel[i.y][i.x])
        .length;
    var itensProximos = mapaAtual.itens
        .where((it) {
          var dx = (it.x - jogador.x).abs();
          var dy = (it.y - jogador.y).abs();
          return dx + dy <= 3;
        }).length;

    sb.writeln('║ Inimigos: $inimigosVisiveis'.padRight(15) + '║');
    sb.writeln('║ Itens: $itensProximos'.padRight(15) + '║');
    sb.writeln('║ Andar ${jogo.andarAtual + 1}/10'.padRight(15) + '║');
    sb.writeln('║              ║');

    if (jogador.efeitos.isNotEmpty) {
      sb.writeln('║ Efeitos:     ║');
      for (var e in jogador.efeitos) {
        sb.writeln('║  - ${e.nome}'.padRight(15) + '║');
      }
    }

    sb.writeln('╚══════════════╝');
    return sb.toString();
  }
}

.padRight(15) + '║' é o truque que mantém a borda direita sempre alinhada. Note que a contagem de inimigos visíveis usa mapaAtual.visivel do desafio 15.5 - integração natural. Variação: tornar a HUD adaptativa (esconde “Efeitos:” se vazio em vez de só pular). Outra: animação - HP que cai pisca em vermelho por 3 frames, ouro acumulado conta gradualmente. Detalhe sensorial pequeno, impacto grande.

Desafio 16.3 - Minimapa do andador

No canto superior direito, renderize um minimap 12x8: @ jogador, E inimigos, $ ouro, . chão, # parede. Escale o mapa grande para pequeno dividindo coordenadas por 2. Mantenha sincronizado enquanto caminha: o @ deve se mover no minimap em tempo real.

Solução de referência. Minimap é “vista de pássaro”: cada célula representa 2x2 (ou 4x4) do mapa real. A regra de redução é simples: mini[y/2][x/2] = mapa[y][x] (com prioridade quando duas células colapsam). Manter sincronizado é fácil - basta recalcular a cada frame; minimaps são pequenos, custo é baixo.

class Minimapa {
  static const w = 12;
  static const h = 8;

  String renderizar(MapaMasmorra mapa, Jogador j, List<Inimigo> inimigos) {
    var escalaX = (mapa.largura / w).ceil();
    var escalaY = (mapa.altura / h).ceil();

    var grade = List.generate(h, (_) => List.filled(w, ' '));

    // pinta tiles compactados
    for (var my = 0; my < h; my++) {
      for (var mx = 0; mx < w; mx++) {
        var x = mx * escalaX;
        var y = my * escalaY;
        if (x >= mapa.largura || y >= mapa.altura) continue;
        var t = mapa.grade[y][x];
        if (t == Tile.chao) grade[my][mx] = '.';
        else grade[my][mx] = '#';
      }
    }

    // sobrepõe inimigos
    for (var i in inimigos) {
      var mx = (i.x / escalaX).floor();
      var my = (i.y / escalaY).floor();
      if (mx >= 0 && my >= 0 && mx < w && my < h) {
        grade[my][mx] = 'E';
      }
    }

    // sobrepõe jogador (prioridade máxima)
    var jmx = (j.x / escalaX).floor();
    var jmy = (j.y / escalaY).floor();
    if (jmx >= 0 && jmy >= 0 && jmx < w && jmy < h) {
      grade[jmy][jmx] = '@';
    }

    var sb = StringBuffer();
    sb.writeln('┌${'─' * w}┐');
    for (var row in grade) {
      sb.writeln('│${row.join('')}│');
    }
    sb.writeln('└${'─' * w}┘');
    return sb.toString();
  }
}

A ordem de pintura (tiles → inimigos → jogador) garante que o jogador sempre apareça por cima, e inimigos sobre o chão. Em variações com FOV, só desenhe tiles descoberto no minimap - mantém o suspense. Para “fog of war” tipo Civilization (já viu, mas não vê agora), use para descoberto-mas-fora-do-raio. Para mapas muito grandes (50x30 num minimap 12x8), agrupar 4x4 vira borrão; aí vale dividir o mapa em “células de interesse” (sala = um pixel) ou rolagem do minimap centrado no jogador.

Desafio 16.4 - Mensagens de Log Coloridas

Crie um sistema de log que: (1) Mostra as últimas 5 mensagens em sequência embaixo do mapa. (2) Adiciona cor às mensagens: vermelho para dano, verde para cura, amarelo para ouro/loot, branco para neutro. (3) Cada nova mensagem desloca as anteriores para cima (efeito “console rolando”).

Solução de referência. O log é uma Queue (ou lista circular) com tamanho fixo - quando enche, o mais antigo sai. Categorizar mensagens (dano, cura, loot, neutro) deixa o estilo central, não espalhado pelo código que gera as mensagens.

enum TipoLog { dano, cura, loot, neutro }

class LinhaLog {
  final String texto;
  final TipoLog tipo;
  LinhaLog(this.texto, this.tipo);
}

class LogJogo {
  static const tamanhoMaximo = 5;
  final List<LinhaLog> linhas = [];

  void adicionar(String texto, TipoLog tipo) {
    linhas.add(LinhaLog(texto, tipo));
    while (linhas.length > tamanhoMaximo) {
      linhas.removeAt(0);
    }
  }

  String _corDe(TipoLog t) {
    switch (t) {
      case TipoLog.dano:   return '\u001B[31m';
      case TipoLog.cura:   return '\u001B[32m';
      case TipoLog.loot:   return '\u001B[33m';
      case TipoLog.neutro: return '\u001B[37m';
    }
  }

  String renderizar() {
    var sb = StringBuffer();
    sb.writeln('--- Log ---');
    // mais antigas em cima, novas embaixo
    for (var l in linhas) {
      sb.writeln('${_corDe(l.tipo)}> ${l.texto}\u001B[0m');
    }
    return sb.toString();
  }
}

void main() {
  var log = LogJogo();
  log.adicionar('Você ataca o orc.', TipoLog.dano);
  log.adicionar('+15 de dano!', TipoLog.dano);
  log.adicionar('Você cura 10 HP.', TipoLog.cura);
  log.adicionar('Encontrou 50 moedas.', TipoLog.loot);
  log.adicionar('O orc cai derrotado.', TipoLog.neutro);
  print(log.renderizar());

  log.adicionar('Sexta mensagem - a primeira sai.', TipoLog.neutro);
  print(log.renderizar());
}

removeAt(0) em List é O(n), aceitável para tamanho 5. Para histórico longo (50+), use Queue do dart:collection que oferece removeFirst em O(1). Variação: timestamp em cada linha ([T42]) para o jogador rastrear quando cada coisa aconteceu - útil para encontrar quando “começou a ser envenenado”. Outra: filtros por tipo (log.filtrarPor(TipoLog.dano)) para o jogador inspecionar histórico de combate isolado.

Boss Final 16.5 - Render Composto: Mapa + HUD + Log

Combine tudo dos capítulos 15 e 16: renderize uma tela completa com (1) mapa colorido com FOV à esquerda, (2) HUD lateral à direita, (3) minimapa no topo direito, (4) log de mensagens embaixo. Use StringBuffer, posicionamento absoluto se quiser (sequências ANSI \x1B[H move cursor home, \x1B[2J limpa tela). Demonstre o resultado completo com 5 cenários: começo, descoberta, combate, vitória, próximo andar.

Solução de referência. Tela composta é puzzle de coordenadas. A abordagem mais simples é renderizar cada componente como List<String> e depois “colar” linha a linha. Sequências ANSI permitem posicionamento absoluto (\u001B[L;Cm move para linha L, coluna C), mas para iniciantes, concatenar linhas é mais previsível.

class TelaCompleta {
  final TelaAscii tela = TelaAscii();
  final Minimapa minimapa = Minimapa();
  final HudLateral hud = HudLateral();
  final LogJogo log = LogJogo();

  void limpar() {
    print('\u001B[2J\u001B[H');  // limpa tela e move cursor para home
  }

  void renderizar(Jogo jogo) {
    limpar();
    var mapa = jogo.andares[jogo.andarAtual];

    // 1. mapa principal (lista de linhas)
    var linhasMapa = tela
        .renderizarParaLinhas(mapa, jogo.jogador, mapa.inimigos);

    // 2. lateral: minimap (8 linhas) + HUD (10 linhas)
    var linhasMini = minimapa.renderizar(mapa, jogo.jogador, mapa.inimigos).split('\n');
    var linhasHud = hud.renderizar(jogador: jogo.jogador, jogo: jogo, mapaAtual: mapa).split('\n');
    var lateral = [...linhasMini, '', ...linhasHud];

    // 3. compõe mapa + lateral linha a linha
    var alturaMax = linhasMapa.length > lateral.length ? linhasMapa.length : lateral.length;
    for (var i = 0; i < alturaMax; i++) {
      var esq = i < linhasMapa.length ? linhasMapa[i] : ' ' * 20;
      var dir = i < lateral.length ? lateral[i] : '';
      print('$esq  $dir');
    }

    // 4. log embaixo
    print('');
    print(log.renderizar());
  }
}

void main() {
  var jogo = Jogo(Jogador('Aldric'));
  var screen = TelaCompleta();

  // cenário 1: começo
  screen.log.adicionar('Bem-vindo à masmorra.', TipoLog.neutro);
  screen.renderizar(jogo);
  // ... próximos cenários simulando ações
}

A função limpar() com \u001B[2J\u001B[H é essencial para “redesenhar” sem o terminal acumular logs antigos. Sem isso, cada frame empilha embaixo e o histórico vira inutilizável. Em terminais que não suportam ANSI (alguns log files), use print('\n' * 30) como fallback. Variação: double-buffering - construir a tela inteira numa string, então printar tudo de uma vez (stdout.write(s)) em vez de print por linha. Reduz flicker no terminal. Para 60fps de verdade, vale dart:ffi com ncurses, mas isso fica fora do escopo do livro.


Capítulo 17 - Aleatoriedade com Propósito

Desafio 17.1 - Modo Speedrun (Semente escolhida)

Adicione um menu ao iniciar que permite inserir uma semente ou deixar aleatória. Exemplos: “Deixe em branco para aleatório, ou digite um número (ex: 1337)”. Use int.tryParse(). Depois, mostre a semente na HUD: “Semente: 1337”. Isso permite streamers e jogadores compartilharem sementes para replay e speedrun.

Solução de referência. Sementes (seeds) são o que separam roguelike “rolar dado” de “rolar dado mas com testemunha” - todo aleatório fica reproduzível. O Random(seed) aceita um int; sem argumento, usa DateTime.now() implicitamente. A semente vira informação pública (mostrada na HUD), e leituras posteriores do _rng ficam determinísticas a partir dela.

import 'dart:io';
import 'dart:math';

class ConfiguracaoJogo {
  final int semente;
  final Random rng;
  ConfiguracaoJogo(this.semente) : rng = Random(semente);

  factory ConfiguracaoJogo.deUsuario() {
    stdout.write('Semente (vazio = aleatório, ex: 1337): ');
    var entrada = stdin.readLineSync()?.trim() ?? '';

    int semente;
    if (entrada.isEmpty) {
      semente = DateTime.now().millisecondsSinceEpoch;
    } else {
      semente = int.tryParse(entrada) ?? DateTime.now().millisecondsSinceEpoch;
    }

    print('>> Jogando com semente: $semente');
    return ConfiguracaoJogo(semente);
  }
}

void main() {
  var cfg = ConfiguracaoJogo.deUsuario();
  // todo o jogo usa cfg.rng - geração de mapa, drops, eventos
  for (var i = 0; i < 5; i++) {
    print('Dado: ${cfg.rng.nextInt(100)}');
  }
}

A semente armazenada em cfg.semente deve ser exibida na HUD (canto inferior) para que screenshots/streams sirvam de evidência. O int.tryParse aceita números grandes (até 2^53 em Dart Web, 2^63 em Dart VM). Variação: aceitar string como “BOSS-RUSH” e convertê-la em hash determinístico (semente = stringDada.hashCode) - permite “sementes nomeadas” que viram apelidos famosos na comunidade. Cuidado: o hashCode de String no Dart 3 pode mudar entre versões do SDK; para portabilidade entre máquinas, melhor SHA-256 truncado ou hash explícito.

Desafio 17.2 - Tabela de Loot

Ao derrotar inimigos, implemente drops ponderados: 70% comum (50-100 ouro), 20% raro (Poção de Vida), 10% épico (Gema = muito ouro). Use Random.nextDouble() para decimalização. Crie uma função Item? resolverDrop(Random random) que retorna o item baseado na chance. Teste derrotando 10 inimigos: a distribuição parece razoável?

Solução de referência. Loot ponderado é a base dos roguelikes: nem todo inimigo dá item raro, mas nem todo morre vazio. A técnica clássica é “tabela cumulativa”: cada item tem peso, soma de pesos vira o range, e o sorteio cai numa faixa. Isso permite ajustar probabilidades sem mexer no resto do código.

import 'dart:math';

class EntradaDrop {
  final double peso;  // chance relativa (não precisa somar 1)
  final Item? Function(Random) gerar;
  EntradaDrop(this.peso, this.gerar);
}

final tabelaDropPadrao = [
  EntradaDrop(70, (r) {
    var ouro = 50 + r.nextInt(51);  // 50-100
    return Item('${ouro} de Ouro', '', 0);
  }),
  EntradaDrop(20, (_) => Consumivel(
    nome: 'Poção de Vida', descricao: '', peso: 100, hpRecuperado: 30, efeito: '+30 HP',
  )),
  EntradaDrop(10, (r) {
    var ouro = 200 + r.nextInt(301);  // 200-500
    return Item('Gema (${ouro}g)', 'Cintilante.', 50);
  }),
];

Item? resolverDrop(Random rng, {List<EntradaDrop>? tabela}) {
  tabela ??= tabelaDropPadrao;
  var total = tabela.fold<double>(0, (s, e) => s + e.peso);
  var roll = rng.nextDouble() * total;

  var acumulado = 0.0;
  for (var entrada in tabela) {
    acumulado += entrada.peso;
    if (roll < acumulado) return entrada.gerar(rng);
  }
  return null;
}

void main() {
  var rng = Random(42);
  var contagem = <String, int>{};
  for (var i = 0; i < 1000; i++) {
    var drop = resolverDrop(rng);
    var nome = drop?.nome.split('(').first.trim() ?? 'nada';
    contagem[nome] = (contagem[nome] ?? 0) + 1;
  }
  contagem.forEach((k, v) => print('$k: $v (${(v / 10).toStringAsFixed(1)}%)'));
}

Saída esperada (com 1000 rolagens): algo perto de 700/200/100. Para 10 rolagens, espere variância alta - “parece razoável” exige amostra maior. Variação: tabela de loot diferente por inimigo (Orc.tabelaDrop, Dragão.tabelaDrop com itens lendários), e tabela diferente por andar (drops melhoram em fundo). Implementa-se mantendo tabela como campo do inimigo e passando para resolverDrop quando ele morre.

Desafio 17.3 - Rolador de Dados (Variação de stats)

Implemente uma classe Rolador com métodos: rolar(int minimo, int maximo), rolarDados(String expressao) (ex: “2d6+3” = dois d6 mais 3), chance(int percentual). Use para gerar HP variável em inimigos: Goblin fraco (d4+5), normal (d6+10), forte (d8+15). Gere 20 inimigos e verifique a variação.

Solução de referência. Notação XdY+Z (X dados de Y lados mais Z) é herança de RPGs de mesa. Implementar o parser dá um exercício útil de regex. rolar(min, max) é trivial. chance(percentual) retorna true com probabilidade dada.

class Rolador {
  final Random _rng;
  Rolador([int? semente]) : _rng = Random(semente);

  int rolar(int minimo, int maximo) =>
      minimo + _rng.nextInt(maximo - minimo + 1);

  bool chance(int percentual) => _rng.nextInt(100) < percentual;

  /// Aceita formato "XdY+Z" ou "XdY-Z" ou "XdY".
  int rolarDados(String expressao) {
    var m = RegExp(r'(\d+)d(\d+)(?:([+-])(\d+))?').firstMatch(expressao);
    if (m == null) throw FormatException('Expressão inválida: $expressao');

    var quantidade = int.parse(m.group(1)!);
    var lados = int.parse(m.group(2)!);
    var sinal = m.group(3);
    var modificador = sinal == null ? 0 : int.parse(m.group(4)!);
    if (sinal == '-') modificador = -modificador;

    var total = 0;
    for (var i = 0; i < quantidade; i++) {
      total += 1 + _rng.nextInt(lados);
    }
    return total + modificador;
  }
}

class Goblin extends Inimigo {
  Goblin.fraco(Rolador r)   : super(nome: 'Goblin Fraco',   hp: r.rolarDados('1d4+5'),  maxHp: 0, ataque: 2, simbolo: 'g', descricao: '');
  Goblin.normal(Rolador r)  : super(nome: 'Goblin',          hp: r.rolarDados('1d6+10'), maxHp: 0, ataque: 3, simbolo: 'G', descricao: '');
  Goblin.forte(Rolador r)   : super(nome: 'Goblin Forte',    hp: r.rolarDados('1d8+15'), maxHp: 0, ataque: 4, simbolo: 'g', descricao: '');
}

void main() {
  var r = Rolador(42);
  for (var i = 0; i < 20; i++) {
    var g = Goblin.normal(r);
    print('Goblin #$i: HP=${g.hp}');
  }
}

O regex (\d+)d(\d+)(?:([+-])(\d+))? captura quantidade, lados e modificador opcional - o (?:...)? é grupo não-capturável e tudo opcional. Para gramática mais rica (“d20 vantagem” = rolar dois e pegar o maior), parsear vira árvore de operações; aí vale uma classe ExpressaoDado com método rolar() em vez de RegExp. Variação: cachear expressões pré-parsadas - se o mesmo 1d4+5 é rolado mil vezes, evitar reparse economiza um pouco.

Desafio 17.4 - Spawn seguro (Longe do jogador)

Ao gerar inimigos aleatoriamente, garanta que estejam a pelo menos 5 tiles do jogador (distância Manhattan). Se a posição aleatória violar isso, tente novamente. Crie bool longeDoJogador(Pos inimigo, Pos jogador, int minDistancia). Teste visualmente: jogador está sempre isolado no spawn.

Solução de referência. Spawn injusto frustra. Se o jogador aparece colado num inimigo forte, perde no primeiro turno sem ter feito nada. “Loop até achar posição boa” é a solução pragmática; o cuidado é evitar loop infinito quando o mapa é pequeno e o raio é grande.

class Pos {
  final int x, y;
  const Pos(this.x, this.y);
}

int distanciaManhattan(Pos a, Pos b) =>
    (a.x - b.x).abs() + (a.y - b.y).abs();

bool longeDoJogador(Pos alvo, Pos jogador, int minDistancia) {
  return distanciaManhattan(alvo, jogador) >= minDistancia;
}

Pos? spawnSeguro({
  required MapaMasmorra mapa,
  required Pos jogador,
  required Random rng,
  int minDistancia = 5,
  int tentativas = 100,
}) {
  for (var i = 0; i < tentativas; i++) {
    var x = rng.nextInt(mapa.largura);
    var y = rng.nextInt(mapa.altura);
    var alvo = Pos(x, y);

    if (mapa.grade[y][x] != Tile.chao) continue;
    if (!longeDoJogador(alvo, jogador, minDistancia)) continue;
    return alvo;
  }
  return null;  // mapa cheio demais ou raio grande demais
}

void main() {
  var mapa = MapaMasmorra(20, 15);
  // ... popula mapa ...
  var jogador = Pos(10, 7);
  var rng = Random(42);

  for (var i = 0; i < 5; i++) {
    var p = spawnSeguro(mapa: mapa, jogador: jogador, rng: rng);
    if (p == null) {
      print('Sem posição segura disponível.');
      continue;
    }
    print('Inimigo #$i: (${p.x}, ${p.y}) - dist ${distanciaManhattan(p, jogador)}');
  }
}

Limitar tentativas em 100 evita loop infinito se o mapa for inviável (todo chão dentro do raio do jogador). Quando o spawnSeguro retorna null, o caller decide: aceitar inimigo perto, gerar mapa novo, ou pular spawn. Variação: pre-calcular lista de posições válidas (mapa.posicoesChao().where(longeDoJogador)) e sortear da lista - converte O(tentativas) em O(1) com preço de memória; útil quando geração roda em loop apertado.

Boss Final 17.5 - Teste de Determinismo (Replicabilidade)

Implemente == e hashCode em suas classes principais (Mapa, Jogador, Inimigo). Escreva testes que verificam: (1) Mapas com semente 42 são idênticos, (2) Semente 43 é diferente, (3) Semente 42 novamente = idêntico à primeira. Isso demonstra que o caos é controlado: mesma semente = mesma jornada. Esse é o fundamento de replays.

Solução de referência. == por valor (em vez do default por identidade) significa duas instâncias com mesmos dados são iguais. Pareado com hashCode, permite usar em Set e Map. A regra-ouro: se sobrescrever ==, obrigatoriamente sobrescrever hashCode - senão coleções quebram silenciosamente.

class MapaMasmorra {
  final int largura, altura;
  final List<List<Tile>> grade;

  MapaMasmorra(this.largura, this.altura, this.grade);

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    if (other is! MapaMasmorra) return false;
    if (largura != other.largura || altura != other.altura) return false;
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        if (grade[y][x] != other.grade[y][x]) return false;
      }
    }
    return true;
  }

  @override
  int get hashCode {
    var h = Object.hash(largura, altura);
    for (var linha in grade) {
      for (var t in linha) {
        h = Object.hash(h, t);
      }
    }
    return h;
  }
}

MapaMasmorra gerarMapaComSemente(int semente, int w, int h) {
  var rng = Random(semente);
  var grade = List.generate(h, (y) => List.generate(w, (x) {
    return rng.nextBool() ? Tile.paredeFirme : Tile.chao;
  }));
  return MapaMasmorra(w, h, grade);
}

void main() {
  var a = gerarMapaComSemente(42, 10, 10);
  var b = gerarMapaComSemente(42, 10, 10);
  var c = gerarMapaComSemente(43, 10, 10);

  assert(a == b, 'Mapas com mesma semente devem ser iguais');
  assert(a != c, 'Sementes diferentes devem produzir mapas diferentes');
  assert(a.hashCode == b.hashCode, 'HashCode deve ser igual para iguais');

  print('Testes passaram. Determinismo confirmado.');
}

Object.hash(a, b, c, ...) é o helper moderno do Dart - combina hashes sem precisar do XOR-manual antigo. Para listas, percorrer e acumular é necessário; alternativa mais ergonômica é Object.hashAll(linhas.expand((l) => l)). Para garantir replay perfeito, todos os Random do jogo devem vir da semente original - se você tem dois Random (um para mapa, outro para drops), inicializar ambos com Random(semente) faz com que rolagens em ordem reproduzam mesmo resultado. Misturar Random() (sem semente) e Random(seed) quebra a propriedade. Variação: serializar o estado completo (Map.toJson()) e re-deserializar - garante que save/load funcionam exatamente como replay.


Capítulo 18 - Geração Procedural: Cavernas e Corredores

Desafio 18.1 - Comparar algoritmos lado a lado

Crie um programa que gera dois mapas com mesmos parâmetros de tamanho: um com Random Walk, outro com Rooms & Corridors. Imprima lado a lado usando StringBuffer. Qual parece mais natural? Qual mais estruturado? Qual você preferiria explorar?

Solução de referência. Comparar lado a lado é exercício de design: a mesma seed produz mapas diferentes, e a sensação tátil de cada um aparece visualmente. Random Walk dá cavernas orgânicas e curvas; Rooms & Corridors dá salas retangulares conectadas por túneis retos. O insight é que estilo de geração impacta a experiência tanto quanto inimigos ou itens.

String compararLado a Lado(MapaMasmorra esq, MapaMasmorra dir) {
  var sb = StringBuffer();
  sb.writeln('${"Random Walk".padRight(esq.largura + 2)}${"Rooms & Corridors"}');
  for (var y = 0; y < esq.altura; y++) {
    for (var x = 0; x < esq.largura; x++) {
      sb.write(tileParaChar(esq.grade[y][x]));
    }
    sb.write('  ');
    for (var x = 0; x < dir.largura; x++) {
      sb.write(tileParaChar(dir.grade[y][x]));
    }
    sb.writeln();
  }
  return sb.toString();
}

void main() {
  var seed = 42;
  var rw = gerarRandomWalk(40, 20, semente: seed, passos: 800);
  var rc = gerarRoomsCorridors(40, 20, semente: seed, salas: 8);
  print(compararLadoALado(rw, rc));
}

A função-chave é manter a mesma altura nos dois mapas - se larguras diferem, o padRight empurra a coluna da direita. Variação: três mapas lado a lado (Random Walk, Rooms & Corridors, Cellular Automata) - mais comparações em paralelo. Para tamanhos diferentes, considere reduzir o maior por amostragem (mapa[y*2][x*2]) para nivelar.

Desafio 18.2 - Tuning de Random Walk

Teste Random Walk com diferentes numPassos: 100, 500, 1000, 5000. Para cada valor, imprima o mapa. Em qual ponto fica “supercavado”? Qual balancia exploração com estrutura? Teste em tamanhos diferentes (80x50, 120x60) e identifique valores ideais.

Solução de referência. Tuning de parâmetros é o trabalho invisível do level designer: ninguém aplaude, mas mapas ruins matam o jogo. A regra empírica para Random Walk é “passos ≈ 30-50% da área”. Para 80x50 = 4000 tiles, 1500-2000 passos é o sweet spot. Acima disso, todo tile vira chão; abaixo, vira ilha isolada.

void main() {
  for (var passos in [100, 500, 1000, 5000]) {
    var mapa = gerarRandomWalk(40, 20, passos: passos, semente: 42);
    var chaos = 0;
    for (var l in mapa.grade) {
      for (var t in l) {
        if (t == Tile.chao) chaos++;
      }
    }
    var pct = (chaos * 100 / (mapa.largura * mapa.altura)).toStringAsFixed(1);
    print('passos=$passos chao=$chaos ($pct%)');
    print(renderizarSimples(mapa));
    print('---');
  }
}

A heurística “chão entre 35% e 55%” funciona como guia: abaixo é apertado demais (jogador trava), acima é arena vazia (sem suspense). Variação: medir “diâmetro” do mapa (longest shortest path via BFS) - mapa bom tem diâmetro entre 1/3 e 2/3 da diagonal. Diâmetros muito pequenos = mapa “achatado”; muito grandes = corredor único interminável.

Desafio 18.3 - Sala Boss

Modifique o gerador Rooms & Corridors para garantir que a última sala gerada é significativamente maior (ex: 15x15). Use-a como “sala do boss final”. Todas as outras salas são menores (6-10 tiles). Imprima o mapa destacando a sala boss com símbolo especial (B ou ).

Solução de referência. Sala “boss” é caso especial no gerador - última iteração tem regras próprias. Marcar tiles dessa sala com tipo diferente (Tile.chaoBoss) permite o renderizador detectar e desenhar com símbolo destacado. Forçar tamanho fixo (15x15) garante que o jogador reconhece a área final mesmo sem mensagem.

MapaMasmorra gerarRoomsCorridorsComBoss(int w, int h, {int semente = 0, int salas = 8}) {
  var rng = Random(semente);
  var mapa = MapaMasmorra(w, h);  // tudo parede
  var retangulos = <Rectangle>[];

  // gera salas normais
  for (var i = 0; i < salas - 1; i++) {
    var rw = 6 + rng.nextInt(5);
    var rh = 6 + rng.nextInt(5);
    var rx = 1 + rng.nextInt(w - rw - 2);
    var ry = 1 + rng.nextInt(h - rh - 2);
    var r = Rectangle(rx, ry, rw, rh);
    if (retangulos.any((existente) => r.intersects(existente))) continue;
    retangulos.add(r);
    esculpirSala(mapa, r, Tile.chao);
  }

  // tenta encaixar sala-boss grande (15x15)
  for (var t = 0; t < 50; t++) {
    var rw = 15, rh = 15;
    if (w < rw + 2 || h < rh + 2) break;
    var rx = 1 + rng.nextInt(w - rw - 2);
    var ry = 1 + rng.nextInt(h - rh - 2);
    var r = Rectangle(rx, ry, rw, rh);
    if (retangulos.any((existente) => r.intersects(existente))) continue;
    retangulos.add(r);
    esculpirSala(mapa, r, Tile.chaoBoss);
    break;
  }

  // conecta salas em ordem (corredores)
  for (var i = 0; i < retangulos.length - 1; i++) {
    abrirCorredor(mapa, retangulos[i].centro, retangulos[i + 1].centro);
  }
  return mapa;
}

A tentativa em 50 iterações de encaixar a sala-boss balanceia “garantir que existe” vs “não travar para sempre”. Se o mapa for pequeno demais para uma sala 15x15, vale a pena retornar null e o caller gerar mapa maior. Variação: sala-boss com disposição arquitetônica (colunas, trono, fosso) - desenhar a sala manualmente com Tile.colunas, Tile.trono em vez de só “chão grande” - cria identidade ao boss. Quando o cap. 21 introduzir spawn de inimigos, o boss aparece automaticamente nessa sala.

Desafio 18.4 - Algoritmo Híbrido

Implemente um terceiro algoritmo que combina ambos: primeiro Random Walk para criar exploração orgânica, depois Rooms & Corridors para adicionar estrutura. Comece com Random Walk pequeno (200 passos), depois tente encaixar 3-5 salas regulares. Valide que as salas não sobrepõem corredores existentes.

Solução de referência. Híbrido pega o melhor dos dois mundos: as cavernas orgânicas do Random Walk dão sensação de antiguidade; as salas retangulares dão pontos de referência. O cuidado é a ordem: gerar Random Walk primeiro evita que ele “encha” salas já criadas. Encaixar salas depois é o desafio - precisam de espaço de parede inteiro disponível.

MapaMasmorra gerarHibrido(int w, int h, {int semente = 0}) {
  var rng = Random(semente);
  var mapa = MapaMasmorra(w, h);
  // tudo começa parede

  // 1. Random Walk orgânico
  var x = w ~/ 2, y = h ~/ 2;
  for (var i = 0; i < 200; i++) {
    mapa.grade[y][x] = Tile.chao;
    var dir = rng.nextInt(4);
    if (dir == 0 && x > 1) x--;
    else if (dir == 1 && x < w - 2) x++;
    else if (dir == 2 && y > 1) y--;
    else if (dir == 3 && y < h - 2) y++;
  }

  // 2. encaixa salas em áreas ainda totalmente paredes
  for (var s = 0; s < 5; s++) {
    for (var t = 0; t < 30; t++) {
      var rw = 5 + rng.nextInt(4);
      var rh = 5 + rng.nextInt(4);
      var rx = 1 + rng.nextInt(w - rw - 2);
      var ry = 1 + rng.nextInt(h - rh - 2);
      if (areaTodaParede(mapa, rx, ry, rw, rh)) {
        esculpirSala(mapa, Rectangle(rx, ry, rw, rh), Tile.chao);
        // conecta com o mais próximo tile de chão
        var alvo = encontrarChaoMaisProximo(mapa, rx + rw ~/ 2, ry + rh ~/ 2);
        abrirCorredor(mapa, Pos(rx + rw ~/ 2, ry + rh ~/ 2), alvo);
        break;
      }
    }
  }
  return mapa;
}

bool areaTodaParede(MapaMasmorra mapa, int x0, int y0, int w, int h) {
  for (var y = y0 - 1; y <= y0 + h; y++) {
    for (var x = x0 - 1; x <= x0 + w; x++) {
      if (x < 0 || y < 0 || x >= mapa.largura || y >= mapa.altura) continue;
      if (mapa.grade[y][x] != Tile.paredeFirme) return false;
    }
  }
  return true;
}

A função areaTodaParede confere uma “moldura” de parede ao redor (y0 - 1 até y0 + h) para evitar salas grudadas em corredores existentes - daria visual feio. encontrarChaoMaisProximo pode ser BFS partindo do centro da nova sala, parando no primeiro tile chão; útil para conectar sem cruzar a sala recém-criada. Variação: parâmetros de proporção (% Random Walk vs % salas) configuráveis para gerar “estilos” diferentes do mesmo gerador.

Desafio 18.5 - Detector de regiões desconexas

Crie uma função int contarRegioesDesconexas(MapaMasmorra mapa) que usa BFS para contar “ilhas” de floor desconectadas. Mapas válidos devem ter apenas 1 região. Rejeite automaticamente mapas com múltiplas regiões. Teste com Random Walk pouco iterado (ele gera ilhas).

Solução de referência. Contar regiões é BFS clássico: começa em qualquer tile de chão, marca tudo alcançável, conta como “1”. Se sobrar chão não-marcado, começa nova região. Repete até cobrir tudo. Output = quantas regiões.

int contarRegioesDesconexas(MapaMasmorra mapa) {
  var visitado = List.generate(
    mapa.altura, (_) => List.filled(mapa.largura, false));

  int regioes = 0;
  for (var y0 = 0; y0 < mapa.altura; y0++) {
    for (var x0 = 0; x0 < mapa.largura; x0++) {
      if (visitado[y0][x0]) continue;
      if (mapa.grade[y0][x0] != Tile.chao) continue;

      regioes++;
      // BFS a partir de (x0, y0)
      var fila = [Pos(x0, y0)];
      while (fila.isNotEmpty) {
        var p = fila.removeLast();
        if (p.x < 0 || p.y < 0) continue;
        if (p.x >= mapa.largura || p.y >= mapa.altura) continue;
        if (visitado[p.y][p.x]) continue;
        if (mapa.grade[p.y][p.x] != Tile.chao) continue;

        visitado[p.y][p.x] = true;
        fila.addAll([
          Pos(p.x + 1, p.y), Pos(p.x - 1, p.y),
          Pos(p.x, p.y + 1), Pos(p.x, p.y - 1),
        ]);
      }
    }
  }
  return regioes;
}

MapaMasmorra gerarValido(int w, int h, {int semente = 0, int maxTentativas = 20}) {
  for (var t = 0; t < maxTentativas; t++) {
    var mapa = gerarRandomWalk(w, h, semente: semente + t, passos: 1500);
    if (contarRegioesDesconexas(mapa) == 1) return mapa;
  }
  throw StateError('Não foi possível gerar mapa conexo em $maxTentativas tentativas.');
}

fila.removeLast() aqui faz DFS (stack), não BFS - para contar regiões, ambos servem. BFS verdadeiro usaria fila.removeAt(0) mas é mais lento (removeAt em lista é O(n)); para BFS rápido use Queue de dart:collection. Variação: em vez de rejeitar mapas com múltiplas regiões, conectar as regiões automaticamente - encontre os dois pontos mais próximos entre regiões diferentes e abra corredor. Resulta em mapas que sempre funcionam, com “respiração extra” entre seções.

Boss Final 18.6 - Sistema de Sementes reproduzível

Modifique MapaMasmorra para aceitar seed opcional. Gere 10 mapas com mesma seed: todos devem ser idênticos. Implemente modo “debug” que exibe a seed na HUD (“Seed: 12345”). Permite que jogadores compartilhem sementes para “desafios reproduzíveis”: “Vence essa seed em 20 minutos!” Isso torna o jogo estratégico.

Solução de referência. O sistema de seeds é a alma do roguelike competitivo: sem ele, “minha run foi azar/sorte” e ninguém compara. Com ele, “speedrun da seed 1337” vira torneio. O segredo é centralizar toda geração aleatória num único Random semeado por seed, propagado para todos os módulos.

class SementeJogo {
  final int valor;
  late final Random rngMapa;
  late final Random rngInimigos;
  late final Random rngDrops;
  late final Random rngEventos;

  SementeJogo(this.valor) {
    // cada subsistema ganha seu próprio Random derivado da mesma raiz.
    // isso evita que mudanças em um (ex: novo drop) desloquem o resto.
    var raiz = Random(valor);
    rngMapa     = Random(raiz.nextInt(1 << 32));
    rngInimigos = Random(raiz.nextInt(1 << 32));
    rngDrops    = Random(raiz.nextInt(1 << 32));
    rngEventos  = Random(raiz.nextInt(1 << 32));
  }
}

void main() {
  var semente = 12345;
  var mapas = <MapaMasmorra>[];
  for (var i = 0; i < 10; i++) {
    var s = SementeJogo(semente);
    var m = gerarRoomsCorridors(40, 20, rng: s.rngMapa);
    mapas.add(m);
  }

  // todos idênticos?
  var primeiro = mapas.first;
  for (var i = 1; i < mapas.length; i++) {
    if (mapas[i] != primeiro) {
      print('Erro: mapa $i diferente do primeiro');
      return;
    }
  }
  print('OK: 10 mapas com seed $semente são idênticos.');
}

Separar rngMapa de rngInimigos é a sutileza profissional: quando você adicionar “inimigo extra” no jogo (que consome rolls do rngInimigos), o mapa permanece igual entre versões. Sem essa separação, qualquer mudança no jogo invalida sementes antigas. Variação: codificar a seed como string memorável - usar palavras (Random("DRAGON-SLAYER".hashCode)) ou esquema próprio de hash que mistura caracteres com bits específicos. Streamers adoram. Para verificação anti-cheat (alguém claims “1m20s em SEED-42” mas modificou o jogo), grave todas as rolagens do rngEventos num replay buffer.


Capítulo 19 - Campo de Visão e a Névoa de Guerra

Desafio 19.1 - Lanterna dinâmica (Raio variável)

Implemente um sistema de “lanternas” com raios diferentes. Crie um enum Luz com variantes: Lanterna(raio: 8), Tocha(raio: 5), Escuridão(raio: 1). O jogador começa com Tocha. Adicione comando "lanterna" para trocar. Cada luz muda o raio do FOV. Teste caminhando com diferentes luzes.

Solução de referência. Enum com campos é o jeito Dart de modelar “tipo + parâmetros associados”. Aqui, cada luz carrega seu próprio raio, e trocar de luz é só atribuir nova instância. O raio passa direto para atualizarVisibilidade do mapa - integração natural com o cap. 15.

enum Luz {
  escuridao(raio: 1, nome: 'Escuridão'),
  tocha(raio: 5, nome: 'Tocha'),
  lanterna(raio: 8, nome: 'Lanterna');

  final int raio;
  final String nome;
  const Luz({required this.raio, required this.nome});
}

class Jogador {
  Luz luzAtual = Luz.tocha;

  void trocarLuz() {
    // ciclo: tocha -> lanterna -> escuridao -> tocha
    var valores = Luz.values;
    var proximoIndice = (valores.indexOf(luzAtual) + 1) % valores.length;
    luzAtual = valores[proximoIndice];
    print('Você troca para: ${luzAtual.nome} (raio ${luzAtual.raio}).');
  }
}

// no game loop, ao processar comando:
void executar(String entrada, Jogador j, MapaMasmorra mapa) {
  if (entrada == 'lanterna') {
    j.trocarLuz();
    mapa.atualizarVisibilidade(j.x, j.y, raio: j.luzAtual.raio);
  }
  // ...
}

O ciclo “escuridão → tocha → lanterna → escuridão” é decisão de UX: o jogador pode preferir uma tecla por luz ('1' tocha, '2' lanterna). Variação: luz com “combustível” - lanterna acende por 50 turnos, depois apaga e volta para escuridão. Implementa-se com int turnosRestantes no campo da luz e decremento por turno. Cap. 22 traz buffs/debuffs e isso encaixa naturalmente.

Desafio 19.2 - Transparência parcial (Vidro)

Modifique shadowcasting para permitir paredes semitransparentes (vidro, grades). Defina Tile.paredeTransparente. Raycast continua através delas (não para), mas marca tiles além como “parcialmente explorado” (símbolo diferente). Permite ver inimigos distante através de vidro, mas com aviso visual.

Solução de referência. Transparência parcial enriquece o sistema de visão com nuance: parede normal bloqueia totalmente, vidro não bloqueia mas marca o que está atrás como “atrás de vidro”. A implementação separa dois conceitos: transparente (deixa raio passar) e opaco (interrompe raio). Renderização ganha terceiro estado: visível direto, visível através de vidro, não visível.

extension TileVisao on Tile {
  bool get bloqueia => this == Tile.paredeFirme || this == Tile.paredeRachada;
  bool get atrasDeVidro => this == Tile.paredeTransparente;
}

class MapaMasmorra {
  // ... como antes
  late List<List<bool>> visivelDireto;
  late List<List<bool>> visivelAtravesVidro;

  void atualizarVisibilidade(int px, int py, {int raio = 5}) {
    // reseta
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        visivelDireto[y][x] = false;
        visivelAtravesVidro[y][x] = false;
      }
    }

    // raycast simples para cada tile no quadrado raio
    for (var dy = -raio; dy <= raio; dy++) {
      for (var dx = -raio; dx <= raio; dx++) {
        if (dx.abs() + dy.abs() > raio) continue;
        _raioParaTile(px, py, px + dx, py + dy);
      }
    }
  }

  void _raioParaTile(int x0, int y0, int x1, int y1) {
    // Bresenham
    var dx = (x1 - x0).abs(), dy = -((y1 - y0).abs());
    var sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
    var erro = dx + dy;
    var x = x0, y = y0;
    var atravessouVidro = false;

    while (true) {
      if (x < 0 || y < 0 || x >= largura || y >= altura) return;
      var t = grade[y][x];
      if (atravessouVidro) {
        visivelAtravesVidro[y][x] = true;
      } else {
        visivelDireto[y][x] = true;
      }
      if (x == x1 && y == y1) return;
      if (t.bloqueia) return;
      if (t.atrasDeVidro) atravessouVidro = true;

      var e2 = 2 * erro;
      if (e2 >= dy) { erro += dy; x += sx; }
      if (e2 <= dx) { erro += dx; y += sy; }
    }
  }
}

O Bresenham é o algoritmo clássico para “linha em grid” - tilizado em qualquer roguelike. A flag atravessouVidro muda o destino dos marca-visível: tudo depois do vidro vira “atrás de vidro”. Renderizar com símbolo diferente ( direto, atrás de vidro) dá a deixa visual ao jogador. Variação: vidro escurece progressivamente (cada vidro atravessado aumenta opacidade); permitir múltiplos vidros encadeados com renderização cada vez mais “embaçada”.

Desafio 19.3 - Mapa de densidade visual (Debug)

Crie modo debug que desenha cada tile colorido por distância ao jogador: próximo (1-2 tiles) = verde claro, distante (5-8) = amarelo, muito distante (8+) = cinza. Ajuda visualizar o raio do FOV. Use caracteres , , ou cores ANSI para gradação.

Solução de referência. Modo debug visualiza o que o jogador “vê” como gradiente. Útil para testar o algoritmo de FOV (vê onde a sombra está caindo, valida o raio). A função usa distância Manhattan ou Chebyshev (max(|dx|, |dy|)) - Chebyshev produz círculos quadrados, Manhattan produz losangos.

String renderizarDebugVisibilidade(MapaMasmorra mapa, int px, int py) {
  var sb = StringBuffer();
  for (var y = 0; y < mapa.altura; y++) {
    for (var x = 0; x < mapa.largura; x++) {
      var dist = (x - px).abs() + (y - py).abs();
      String char;
      if (dist <= 2)       char = '\u001B[92m▓\u001B[0m';  // verde claro
      else if (dist <= 5)  char = '\u001B[33m▒\u001B[0m';  // amarelo
      else if (dist <= 8)  char = '\u001B[90m░\u001B[0m';  // cinza
      else                 char = ' ';                       // fora do FOV total
      if (x == px && y == py) char = '\u001B[92m@\u001B[0m';
      sb.write(char);
    }
    sb.writeln();
  }
  return sb.toString();
}

O '\u001B[92m' é verde brilhante; [32m seria verde escuro. ANSI tem 16 cores básicas, depois cores 256 e RGB - para debug, as 16 bastam. Variação: incluir contadores numéricos (cada tile mostra sua distância exata) - útil para ajustar fórmulas sem chutar. print('${dist}'.padLeft(2)) em vez de char colorido vira mapa numérico.

Desafio 19.4 - Piscadas de movimento

Tiles que entraram no FOV este turno piscam (símbolo especial, ex: * em vez de .) por 1 turno. Simula o olho humano capturando movimento novo. Dica: compare tileVisivelAnterior com tileVisivelAgora, destaque adições.

Solução de referência. “Piscar” requer manter dois snapshots do FOV: o atual e o anterior. Tiles em agora mas não em anterior = entrou agora; recebe destaque por 1 frame. Após render, anterior = agora para o próximo turno.

class MapaMasmorra {
  // ...
  late List<List<bool>> visivelAgora;
  late List<List<bool>> visivelAnterior;

  void avancarTurno() {
    // copia agora para anterior
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        visivelAnterior[y][x] = visivelAgora[y][x];
      }
    }
  }

  bool entrouNoFovEsteTurno(int x, int y) {
    return visivelAgora[y][x] && !visivelAnterior[y][x];
  }

  String renderizar(int px, int py) {
    var sb = StringBuffer();
    for (var y = 0; y < altura; y++) {
      for (var x = 0; x < largura; x++) {
        if (!visivelAgora[y][x]) {
          sb.write(' ');
          continue;
        }
        if (entrouNoFovEsteTurno(x, y)) {
          sb.write('*');  // destaque
        } else {
          sb.write(tileParaChar(grade[y][x]));
        }
      }
      sb.writeln();
    }
    return sb.toString();
  }
}

// game loop:
void turno(Jogador j, MapaMasmorra mapa) {
  // 1. processa ação do jogador (mover, atacar, etc.)
  // 2. atualiza FOV (preenche visivelAgora)
  mapa.atualizarVisibilidade(j.x, j.y, raio: j.luzAtual.raio);
  // 3. renderiza (mostra piscadas)
  print(mapa.renderizar(j.x, j.y));
  // 4. snapshot para o próximo turno
  mapa.avancarTurno();
}

A sequência renderizar antes de avançar turno é crítica - se você inverter, visivelAnterior já é visivelAgora e nada pisca. Variação: piscar com cor (\u001B[1;33m*\u001B[0m = amarelo brilhante) em vez de só * - sinal visual mais forte. Outra: duração customizada (pisca por 2 turnos) - troca o boolean por contador int turnosDesdeQueEntrou e renderiza com base no valor (0 = brilhante, 1 = médio, 2+ = normal).

Desafio 19.5 - Inimigos escondidos (Fora do FOV)

Inimigos só aparecem se dentro de FOV. Fora do FOV, não renderizam (mas continuam existindo, movendo-se). Crie um “flanqueador” que sai do FOV deliberadamente, torna-se invisível, depois toca o jogador de surpresa. Dica: renderize como ? enquanto fora do FOV se o jogador “sentir presença”.

Solução de referência. A regra é simples: ao renderizar inimigo, só desenhe se mapa.visivel[inimigo.y][inimigo.x]. A magia do “flanqueador” é IA que busca posições fora do FOV - movimento orientado a evitar visão. Em código, isso vira “se vejo o jogador, recuo para tile não-visto pelo jogador; se não vejo, sigo para a última posição conhecida”.

class Flanqueador extends Inimigo {
  Pos? ultimaPosicaoJogadorVista;

  Flanqueador()
      : super(nome: 'Flanqueador', hp: 10, maxHp: 10, ataque: 4, simbolo: 'F', descricao: 'Um vulto que se esquiva da luz.');

  Pos planejarMovimento(Pos minha, Jogador j, MapaMasmorra mapa) {
    var jogadorMeVe = mapa.visivelAgora[y][x];

    if (jogadorMeVe) {
      ultimaPosicaoJogadorVista = Pos(j.x, j.y);
      // foge: escolhe vizinho não-visível
      return _escolherTileForaDoFov(minha, mapa) ?? minha;
    } else if (ultimaPosicaoJogadorVista != null) {
      // contorna até flanquear
      return _proximoPassoEvitandoFov(minha, ultimaPosicaoJogadorVista!, mapa);
    }
    return minha;  // sem alvo, fica parado
  }

  Pos? _escolherTileForaDoFov(Pos atual, MapaMasmorra mapa) {
    var vizinhos = [
      Pos(atual.x + 1, atual.y), Pos(atual.x - 1, atual.y),
      Pos(atual.x, atual.y + 1), Pos(atual.x, atual.y - 1),
    ];
    vizinhos.shuffle();
    for (var v in vizinhos) {
      if (v.x < 0 || v.y < 0 || v.x >= mapa.largura || v.y >= mapa.altura) continue;
      if (mapa.grade[v.y][v.x] != Tile.chao) continue;
      if (!mapa.visivelAgora[v.y][v.x]) return v;
    }
    return null;
  }

  // _proximoPassoEvitandoFov: BFS para o alvo, preferindo tiles fora do FOV
  // (implementação omitida; ver cap. 21 pathfinding)
}

// no render:
void renderizarInimigos(MapaMasmorra mapa, List<Inimigo> inimigos) {
  for (var inim in inimigos) {
    if (mapa.visivelAgora[inim.y][inim.x]) {
      // desenha normal
    }
    // senão: invisível ao jogador
  }
}

A propriedade emergente é interessante: o jogador “perde” o flanqueador, anda mais alguns passos, e o flanqueador aparece bem atrás dele. Sensação clássica de horror. Variação: “sentir presença” - se o flanqueador estiver a ≤3 tiles fora do FOV, renderize como ? num tile aleatório dentro do FOV. Dá pista, mas mantém o suspense. Sons de pegadas no log ('Você ouve passos atrás.') reforçam o efeito.

Boss Final 19.6 - FOV em múltiplos andares

Estenda FOV para andares (subsolos). Tiles em andares abaixo são vistos com opacidade (símbolo diferente, menos perceptível). Escadas abertas aumentam raio para andares abaixo. Implementação: passe andarAtual como parâmetro, recalcule FOV com raio reduzido para cada andar (-50% por nível).

Solução de referência. FOV multi-andar é puzzle estético - mostrar profundidade num grid 2D requer convenção. Uma abordagem: transparência decrescente. O andar atual em cor cheia; andar imediatamente abaixo em cinza claro; mais abaixo, quase invisível. Funciona melhor com escadas como “janelas” - vê o andar abaixo só perto delas.

class JogoMultiAndar {
  final List<MapaMasmorra> andares;
  int andarAtual;
  Jogador jogador;

  JogoMultiAndar(this.andares, this.andarAtual, this.jogador);

  String renderizar() {
    var sb = StringBuffer();
    var mapa = andares[andarAtual];

    for (var y = 0; y < mapa.altura; y++) {
      for (var x = 0; x < mapa.largura; x++) {
        if (x == jogador.x && y == jogador.y) {
          sb.write('@');
          continue;
        }
        // andar atual: render normal
        if (mapa.visivelAgora[y][x]) {
          sb.write(tileParaChar(mapa.grade[y][x]));
          continue;
        }
        // se há escada visível por perto, mostra um vislumbre do andar de baixo
        var vislumbre = _vislumbreAndarAbaixo(x, y);
        if (vislumbre != null) {
          sb.write('\u001B[90m$vislumbre\u001B[0m');
        } else if (mapa.descoberto[y][x]) {
          sb.write('\u001B[90m░\u001B[0m');
        } else {
          sb.write(' ');
        }
      }
      sb.writeln();
    }
    return sb.toString();
  }

  String? _vislumbreAndarAbaixo(int x, int y) {
    if (andarAtual + 1 >= andares.length) return null;
    var prox = andares[andarAtual + 1];

    // se há escadaDesce no andar atual a até 3 tiles, mostra o tile do andar abaixo
    for (var dy = -3; dy <= 3; dy++) {
      for (var dx = -3; dx <= 3; dx++) {
        var nx = x + dx, ny = y + dy;
        if (nx < 0 || ny < 0) continue;
        if (nx >= prox.largura || ny >= prox.altura) continue;
        var t = andares[andarAtual].grade[ny.clamp(0, prox.altura - 1)][nx.clamp(0, prox.largura - 1)];
        if (t == Tile.escadaDesce && dx.abs() + dy.abs() <= 3) {
          return tileParaChar(prox.grade[y][x]).toLowerCase();
        }
      }
    }
    return null;
  }
}

Esse efeito de “ver através do chão” é interessante mas pode confundir. Variação mais sóbria: ao passar sobre uma escada, abrir uma sub-janela (no canto) mostrando o andar abaixo. Pessoa não confunde com mapa principal. A regra de raio reduzido (-50% por nível) é uma convenção visual; em jogos reais, pode-se variar conforme tipo de iluminação (lanterna mágica vê 2 andares baixo, tocha só 1).


Capítulo 20 - Entidades no Mapa: Inimigos, Itens, Escadas

Desafio 20.1 - Armadilha (Entidade customizada)

Crie EntidadeArmadilha: ao ser tocada, aplica dano ao jogador (5 HP) e dispara mensagem. O símbolo é ^. Retorna false de aoTocada(), permanecendo no mapa. Adicione com 20% de chance em cada sala. Dica: verifique if (visitante is Jogador), depois aplique dano via visitante.sofrerDano(5).

Solução de referência. Armadilha é o primeiro tipo de entidade que persiste depois de “tocada” - difere de item (some) e inimigo (some quando derrotado). O retorno false em aoTocada sinaliza ao gerenciador “não me remova”. O símbolo ^ lembra ponta de estaca, padrão clássico de roguelike.

abstract class Entidade {
  int x, y;
  String simbolo;
  Entidade(this.x, this.y, this.simbolo);

  /// Retorna true se a entidade deve ser removida do mapa após o toque.
  bool aoTocada(dynamic visitante);
}

class EntidadeArmadilha extends Entidade {
  int dano;
  bool ativada = false;

  EntidadeArmadilha(int x, int y, {this.dano = 5}) : super(x, y, '^');

  @override
  bool aoTocada(dynamic visitante) {
    if (visitante is Jogador) {
      visitante.sofrerDano(dano);
      print('Armadilha! Você perde $dano HP.');
    }
    ativada = true;
    return false;  // continua no mapa
  }
}

void popularArmadilhas(MapaMasmorra mapa, List<Rectangle> salas, Random rng) {
  for (var sala in salas) {
    if (rng.nextDouble() < 0.20) {
      var x = sala.x + 1 + rng.nextInt(sala.w - 2);
      var y = sala.y + 1 + rng.nextInt(sala.h - 2);
      mapa.entidades.add(EntidadeArmadilha(x, y));
    }
  }
}

A flag ativada é útil para variações: armadilha que muda de símbolo após disparada (^.), ou que dispara só uma vez (if (ativada) return false; pula o dano). Para tornar visível ao jogador atento (não esconder totalmente), aplique Tile.armadilha = '·' quando descoberta - o ponto baixo sugere “olha algo aqui”. Variação: tipos de armadilha (espinhos = dano, gás = veneno por turnos, alçapão = troca de andar). O polimorfismo cuida do despacho.

Desafio 20.2 - Tipos de Item

Estenda Item com propriedades: crie enum TipoItem com valores OURO, POCAO_VIDA, POCAO_MANA, GEMA, CHAVE. Cada tipo tem efeito único ao ser coletado. POCAO_VIDA restaura 25 HP, GEMA aumenta ouro, CHAVE abre portas. Implemente efeito em aoTocada().

Solução de referência. Enum com comportamento polimórfico era padrão Java; em Dart 3, enums podem ter métodos diretamente, mas para efeitos complexos com estado, ainda é mais limpo usar subclasses. Aqui vou seguir a sugestão do enunciado e implementar dispatch via switch.

enum TipoItem { ouro, pocaoVida, pocaoMana, gema, chave }

class EntidadeItem extends Entidade {
  final TipoItem tipo;
  final int valor;

  EntidadeItem(int x, int y, this.tipo, {this.valor = 1})
      : super(x, y, _simboloDe(tipo));

  static String _simboloDe(TipoItem t) {
    switch (t) {
      case TipoItem.ouro:      return '\$';
      case TipoItem.pocaoVida: return '!';
      case TipoItem.pocaoMana: return '?';
      case TipoItem.gema:      return '*';
      case TipoItem.chave:     return 'k';
    }
  }

  @override
  bool aoTocada(dynamic visitante) {
    if (visitante is! Jogador) return false;
    var j = visitante;

    switch (tipo) {
      case TipoItem.ouro:
        j.ouro += valor;
        print('+\$$valor ouro.');
      case TipoItem.pocaoVida:
        j.curar(25);
        print('+25 HP.');
      case TipoItem.pocaoMana:
        j.mana += 20;
        print('+20 mana.');
      case TipoItem.gema:
        j.ouro += 100;
        print('Gema! +100 ouro.');
      case TipoItem.chave:
        j.chaves++;
        print('Chave obtida.');
    }
    return true;  // item some ao ser coletado
  }
}

switch exaustivo no Dart 3 (sem default) garante que se você adicionar TipoItem.bomba, o compilador avisa. A flag de retorno true para itens (some) vs false para armadilhas (persiste) é a regra geral do sistema. Variação: cada tipo com efeito mais elaborado - pocaoMana recupera quantidade igual ao nível do jogador, gema varia entre 50 e 200 conforme tamanho. Quando o cap. 26 introduzir Strategy formal, esse switch vira tipo.efeito.aplicar(j).

Desafio 20.3 - Inimigos por dificuldade

Estenda GeradorEntidades para aceitar int andar. Conforme o andar aumenta, inimigos ficam mais fortes (HP += andar * 2), mais raros e variados. Andar 5+: aparece um Orc. Andar 10+: Dragão. Use random.nextInt(andar) para verificar se spawna inimigo raro.

Solução de referência. Escalar inimigos por andar é o que mantém o jogo desafiador “para sempre”. A regra “HP += andar * 2” é linear; em vez disso, dá para curvar (HP * 1.15^andar) para crescimento exponencial. O parâmetro andar filtra o tipo do inimigo (if (andar >= 5) ...) e ajusta os stats.

class GeradorEntidades {
  final Random rng;
  GeradorEntidades(this.rng);

  Inimigo gerarInimigo(int andar) {
    var roll = rng.nextInt(100);

    Inimigo base;
    if (andar >= 10 && roll < 5) {
      base = Dragao();
    } else if (andar >= 5 && roll < 15) {
      base = Orc();
    } else if (roll < 60) {
      base = Goblin();
    } else {
      base = Esqueleto();
    }

    // escala stats
    base.hp += andar * 2;
    base.maxHp = base.hp;
    base.ataque += andar ~/ 3;
    return base;
  }
}

void main() {
  var g = GeradorEntidades(Random(42));
  for (var andar in [1, 5, 10, 15]) {
    print('--- Andar $andar ---');
    for (var i = 0; i < 5; i++) {
      var inim = g.gerarInimigo(andar);
      print('  ${inim.runtimeType} HP=${inim.hp} ATK=${inim.ataque}');
    }
  }
}

andar ~/ 3 (divisão inteira) faz ataque crescer mais devagar que HP - inimigos mais “tankzudos” que “fortes”. A escolha de fórmulas é design: dragão no andar 10 com HP base 80 + 10*2 = 100 é tankzudo; quer ele mais letal, troque para ataque += andar. Variação: inimigos “lendários” com cooldown global - só aparece um por andar (bossSpawnadoAndar[andar] = false). Para roguelike puro, gere mais inimigos por andar (quantidade = 3 + andar // 2).

Desafio 20.4 - Colisão com eventos

Integre entidades com movimento: ao jogador tentar se mover, chame mapa.entidadeEm(x, y). Se houver, chame aoTocada(jogador). Implemente um log visual no HUD mostrando últimas ações: “Coletou Ouro”, “Levou dano de Armadilha”, etc. Use List<String> logAcoes para rastrear.

Solução de referência. Cada movimento do jogador passa por um pipeline: validar destino → mover → resolver entidades nas casas tocadas. O log centraliza tudo num LogJogo (reuso do cap. 16). A função entidadeEm é busca linear simples; para mapas com 100+ entidades, vale um Map<Pos, Entidade> para lookup O(1).

class MapaMasmorra {
  final List<Entidade> entidades = [];

  Entidade? entidadeEm(int x, int y) {
    for (var e in entidades) {
      if (e.x == x && e.y == y) return e;
    }
    return null;
  }

  void removerEntidade(Entidade e) => entidades.remove(e);
}

bool tentarMover(Jogador j, int dx, int dy, MapaMasmorra mapa, LogJogo log) {
  var nx = j.x + dx, ny = j.y + dy;
  if (nx < 0 || ny < 0 || nx >= mapa.largura || ny >= mapa.altura) return false;
  if (mapa.grade[ny][nx] == Tile.paredeFirme) return false;

  j.x = nx;
  j.y = ny;

  var entidade = mapa.entidadeEm(nx, ny);
  if (entidade != null) {
    var deveRemover = entidade.aoTocada(j);
    log.adicionar('Tocou: ${entidade.runtimeType}', TipoLog.neutro);
    if (deveRemover) mapa.removerEntidade(entidade);
  }
  return true;
}

A separação “mover primeiro, resolver entidade depois” é importante: se a entidade matar o jogador (armadilha de espinhos no andar 20), ele ainda está na casa correta no momento da morte. Variação: múltiplas entidades na mesma casa - troque o método para entidadesEm(x, y) -> List<Entidade> e itere. Útil quando “tudo cai num mesmo tile” (ouro + chave dropados ao matar boss).

Desafio 20.5 - Spawn inteligente (Distribuição)

Inimigos nunca aparecem a menos de 20 tiles da entrada (distância Manhattan). Itens são distribuídos em salas diferentes. Escadas ficam no fundo (distante). Passe as salas ao gerador, determine sala aleatória, spawn dentro dela.

Solução de referência. Distribuição inteligente respeita o “ritmo” do level. Inimigos perto da entrada matam o jogador antes que aprenda; itens concentrados em uma sala sufocam o resto do mapa; escadas perto da entrada anulam exploração. A regra é: cada tipo de entidade tem filtro de spawn próprio, e a função geradora respeita.

class DistribuidorEntidades {
  final Random rng;
  DistribuidorEntidades(this.rng);

  void distribuir({
    required MapaMasmorra mapa,
    required Pos entrada,
    required List<Rectangle> salas,
    required int andar,
  }) {
    // 1. inimigos: ≥ 20 tiles da entrada
    var quantInimigos = 3 + andar;
    var tentativas = 0;
    while (mapa.entidades.whereType<Inimigo>().length < quantInimigos
        && tentativas < quantInimigos * 5) {
      tentativas++;
      var sala = salas[rng.nextInt(salas.length)];
      var x = sala.x + 1 + rng.nextInt(sala.w - 2);
      var y = sala.y + 1 + rng.nextInt(sala.h - 2);
      if ((x - entrada.x).abs() + (y - entrada.y).abs() < 20) continue;
      mapa.entidades.add(GeradorEntidades(rng).gerarInimigo(andar));
    }

    // 2. itens: pelo menos 1 por sala (excluindo a primeira/entrada)
    for (var i = 1; i < salas.length; i++) {
      var sala = salas[i];
      var tipo = TipoItem.values[rng.nextInt(TipoItem.values.length)];
      mapa.entidades.add(EntidadeItem(
        sala.x + sala.w ~/ 2,
        sala.y + sala.h ~/ 2,
        tipo,
      ));
    }

    // 3. escada para o próximo andar: na sala mais distante da entrada
    var maisDistante = salas.reduce((a, b) {
      var distA = (a.x - entrada.x).abs() + (a.y - entrada.y).abs();
      var distB = (b.x - entrada.x).abs() + (b.y - entrada.y).abs();
      return distA > distB ? a : b;
    });
    mapa.grade[maisDistante.y + maisDistante.h ~/ 2][maisDistante.x + maisDistante.w ~/ 2] =
        Tile.escadaDesce;
  }
}

A função salas.reduce é jeito funcional de “achar o máximo” - itera comparando dois a dois, mantendo o vencedor. Variação: dificuldade gradiente espacial - inimigos perto da entrada fracos (Goblin), longe da entrada fortes (Orc, Dragão) - calcule distância da entrada e ajuste o gerador. Cria sensação de “estou fundo” sem precisar mudar andar.

Boss Final 20.6 - IA de Inimigos (Movimentação)

Adicione método moveIA(Pos jogadorPos) em Inimigo que retorna nova posição. Se jogador está no FOV, persegue (distância < 10 tiles). Senão, anda aleatoriamente. Implemente no turno inimigo: primeiro inimigos se movem, depois jogador age. Crie um InimigoPerseguidor que tenta se aproximar do jogador.

Solução de referência. IA de movimentação é o primeiro nível de “vida” no mapa. Estados: patrulhando (anda aleatoriamente quando jogador não visível), perseguindo (escolhe vizinho que reduz distância ao jogador). Trocar de estado depende de FOV.

abstract class Inimigo extends Entidade {
  // ... campos existentes
  Pos moveIA(Pos jogadorPos, MapaMasmorra mapa);
}

class InimigoPerseguidor extends Inimigo {
  InimigoPerseguidor(int x, int y) : super(x, y, 'P');

  @override
  Pos moveIA(Pos jogadorPos, MapaMasmorra mapa) {
    var distancia = (x - jogadorPos.x).abs() + (y - jogadorPos.y).abs();

    if (distancia < 10 && mapa.visivelAgora[y][x]) {
      return _passoEmDirecaoA(jogadorPos, mapa);
    } else {
      return _passoAleatorio(mapa);
    }
  }

  Pos _passoEmDirecaoA(Pos alvo, MapaMasmorra mapa) {
    var dx = (alvo.x - x).sign;  // -1, 0, +1
    var dy = (alvo.y - y).sign;
    // tenta horizontal primeiro, vertical depois
    var candidatos = [Pos(x + dx, y), Pos(x, y + dy)];
    for (var c in candidatos) {
      if (c.x == x && c.y == y) continue;
      if (mapa.grade[c.y][c.x] == Tile.chao) return c;
    }
    return Pos(x, y);  // bloqueado
  }

  Pos _passoAleatorio(MapaMasmorra mapa) {
    var rng = Random();
    var vizinhos = [
      Pos(x + 1, y), Pos(x - 1, y), Pos(x, y + 1), Pos(x, y - 1),
    ]..shuffle(rng);
    for (var v in vizinhos) {
      if (v.x < 0 || v.y < 0) continue;
      if (v.x >= mapa.largura || v.y >= mapa.altura) continue;
      if (mapa.grade[v.y][v.x] == Tile.chao) return v;
    }
    return Pos(x, y);
  }
}

void turnoInimigos(MapaMasmorra mapa, Jogador j) {
  for (var e in mapa.entidades.whereType<Inimigo>()) {
    var novo = e.moveIA(Pos(j.x, j.y), mapa);
    if (novo.x == j.x && novo.y == j.y) {
      // bateu no jogador: ataque em vez de mover
      j.sofrerDano(e.ataque);
    } else {
      e.x = novo.x;
      e.y = novo.y;
    }
  }
}

.sign em int retorna -1, 0 ou +1 conforme negativo/zero/positivo - útil para “direção do passo”. A IA “tenta horizontal primeiro, vertical depois” cria movimento meio errático ao redor de paredes - jeito clássico de greedy. A versão “boa” usa BFS para pathfinding (cap. 24). Variação: estados explícitos com enum (Patrulhando, Perseguindo, Fugindo) e transições controladas por HP, jogador visível, tempo desde último ataque - aproxima de FSM (state machine). Quando o cap. 27 introduzir Behavior Trees, essa IA vira nó.


Capítulo 21 - Dungeon Crawl: Juntando Tudo

Desafio 21.1 - Melhorar o HUD

Adicione mais informações na HUD: nível atual, XP para próximo nível, quantos inimigos você derrotou neste andar.

Solução de referência. A HUD enriquecida é o painel de “como vou indo”. XP costuma seguir fórmula exponencial: xpProximoNivel = nivel * 100. Contar inimigos derrotados por andar exige um contador resetado quando muda de andar - útil para estatísticas e conquistas.

class Jogador with Combatente {
  String nome;
  int nivel = 1;
  int xp = 0;
  int xpProximoNivel = 100;
  int inimigosNoAndar = 0;
  // ... outros campos

  void ganharXp(int valor) {
    xp += valor;
    while (xp >= xpProximoNivel) {
      xp -= xpProximoNivel;
      nivel++;
      xpProximoNivel = nivel * 100;
      maxHp += 10;
      hp = maxHp;
      print('Nível! Agora $nivel. HP máximo +10.');
    }
  }

  void resetarContadorAndar() {
    inimigosNoAndar = 0;
  }
}

String hudCompleto(Jogador j, int andar) {
  var sb = StringBuffer();
  sb.writeln('╔═══════════════════════╗');
  sb.writeln('║ ${j.nome.padRight(22)}║');
  sb.writeln('║ Nível: ${j.nivel.toString().padRight(15)}║');
  sb.writeln('║ XP: ${j.xp}/${j.xpProximoNivel}'.padRight(24) + '║');
  sb.writeln('║ HP: ${j.hp}/${j.maxHp}'.padRight(24) + '║');
  sb.writeln('║ Andar: $andar'.padRight(24) + '║');
  sb.writeln('║ Mortos: ${j.inimigosNoAndar}'.padRight(24) + '║');
  sb.writeln('╚═══════════════════════╝');
  return sb.toString();
}

O while (xp >= xpProximoNivel) em vez de if cobre level-ups múltiplos com um único drop grande (matou boss, ganhou 500 XP, subiu 3 níveis de uma vez). Variação: barra de XP visual ([▓▓▓░░] 47/100) usando a função do desafio 6.2. Quando o cap. 25 introduzir conquistas, o inimigosNoAndar alimenta automaticamente “Limpou um andar sem morrer”.

Desafio 21.2 - Tela de Pausa

Implemente um comando p (pause) que para o jogo e mostra um menu: continuar, salvar, sair.

Solução de referência. Pausa em jogo de turno é mais simples que em real-time - o game loop já espera input. O comando p abre um sub-loop que aceita só opções específicas; ao escolher “continuar”, retorna ao loop principal. “Salvar” exige integração com cap. 18 (persistência); “sair” usa exit(0).

import 'dart:io';

enum AcaoPausa { continuar, salvar, sair }

AcaoPausa menuPausa() {
  while (true) {
    print('');
    print('╔══════════════════════╗');
    print('║      JOGO PAUSADO    ║');
    print('╠══════════════════════╣');
    print('║ [c] Continuar        ║');
    print('║ [s] Salvar partida   ║');
    print('║ [q] Sair             ║');
    print('╚══════════════════════╝');
    stdout.write('> ');
    var entrada = (stdin.readLineSync() ?? '').toLowerCase().trim();
    switch (entrada) {
      case 'c': case 'continuar': return AcaoPausa.continuar;
      case 's': case 'salvar':    return AcaoPausa.salvar;
      case 'q': case 'sair':      return AcaoPausa.sair;
      default: print('Opção inválida.');
    }
  }
}

void processarComando(String entrada, Jogador j, Jogo jogo) {
  if (entrada == 'p' || entrada == 'pause') {
    var acao = menuPausa();
    switch (acao) {
      case AcaoPausa.continuar: return;
      case AcaoPausa.salvar:
        salvarPartida(jogo);
        print('Partida salva.');
      case AcaoPausa.sair:
        print('Até a próxima, ${j.nome}.');
        exit(0);
    }
  }
  // ... outros comandos
}

O exit(0) finaliza imediatamente o processo - não passa pelos destrutores nem pelo try/finally de cima. Para gameloop com cleanup necessário, prefira throw _ExitException() capturado no topo. Variação: “salvar e continuar” - depois de salvar, retorna ao jogo em vez de só anunciar. Mais ergonômico para sessões longas.

Desafio 21.3 - Animação de Movimento

Adicione um pequeno delay ao movimento (Future.delayed() ou sleep()) para que o jogador veja os passos acontecendo lentamente na tela.

Solução de referência. Delay no movimento simula “tempo” entre ações. sleep() é síncrono - bloqueia thread; Future.delayed é assíncrono - precisa await. Para CLI, sleep é mais simples e adequado.

import 'dart:io';

void moverComAnimacao(Jogador j, int dx, int dy, MapaMasmorra mapa) {
  if (dx == 0 && dy == 0) return;

  var passosDx = dx.sign;
  var passosDy = dy.sign;

  // se o movimento for multi-tile (atalho "ir norte 5"), anima cada passo
  var passos = (dx.abs() > dy.abs() ? dx.abs() : dy.abs());

  for (var i = 0; i < passos; i++) {
    var nx = j.x + passosDx;
    var ny = j.y + passosDy;
    if (mapa.grade[ny][nx] != Tile.chao) break;

    j.x = nx;
    j.y = ny;
    print(renderizar(mapa, j));  // redesenha tela
    sleep(Duration(milliseconds: 80));
  }
}

Duration(milliseconds: 80) é o equivalente a ~12 fps - fluido o suficiente sem virar slow-motion. Valores acima de 200ms ficam lentos demais; abaixo de 50ms perde-se o efeito. Variação: animação dependente do tipo de movimento - andar lento (sleep(120)), correr ('correr norte' → 30ms por passo). Outra: parar animação se aparecer inimigo (auto-cancel). Cuidado com sleep em modo de teste - bloqueia todos os asserts; mock o “sleep” via injeção (void Function(Duration) sono = sleep).

Desafio 21.4 - Log de Eventos

Adicione um List<String> logEventos que registra o que aconteceu: “Você matou Zumbi”, “Pegou ouro”, “Subiu de nível”. Mostre os últimos 3-5 eventos na HUD.

Solução de referência. Reuso direto do LogJogo do cap. 16 - ele já tem tamanhoMaximo, categorização e renderização colorida. A diferença aqui é integrar nos pontos de “evento”: morte de inimigo, coleta de item, level up. Cada um chama log.adicionar(...).

void aoMatar(Jogador j, Inimigo morto, LogJogo log) {
  j.inimigosNoAndar++;
  j.ganharXp(morto.maxHp * 2);
  log.adicionar('Você derrotou ${morto.nome}.', TipoLog.neutro);
  log.adicionar('+${morto.maxHp * 2} XP.', TipoLog.cura);
}

void aoColetar(Jogador j, EntidadeItem item, LogJogo log) {
  // item.aoTocada já é chamado antes; aqui só registra
  log.adicionar('Pegou ${item.tipo.name}.', TipoLog.loot);
}

void integrarLogNoGameLoop(Jogador j, MapaMasmorra mapa, LogJogo log) {
  // depois de turno do jogador / inimigos:
  // mostra HUD + últimos 5 eventos
  print(hudCompleto(j, mapa.andar));
  print(log.renderizar());
}

A regra de design é: eventos são fatos, não estados. “Derrotou Zumbi” é um fato; “HP=80/100” é estado. O log captura sequência de fatos, não a foto atual. Variação: log com timestamps relativos ([T+2], indicando “dois turnos atrás”) para narrativa mais clara. Outra: separar dois logs lado a lado - LogCombate (vermelho/verde) e LogLoot (amarelo) - menos linhas, organização melhor.

Boss Final 21.5 - Cores ANSI - Cores no Terminal

Volte à classe TelaAscii do Capítulo 16 e adicione suporte a cores ANSI. Cada tipo de tile deve ter sua cor: verde para chão (.), cinza para paredes (#), vermelho para inimigos, amarelo para ouro, azul para escada (>).

Solução de referência. Bons jogos terminais não usam só verde-de-CRT; cores trazem clareza imediata. A função colorir do cap. 16 (desafio 16.1) já existe; o que falta é uma tabela completa “tipo de tile/entidade → código de cor”.

class PaletaTerminal {
  static const reset      = '\u001B[0m';
  static const verdeChao  = '\u001B[32m';
  static const cinzaParede = '\u001B[37m';
  static const vermelhoInim = '\u001B[31m';
  static const amareloOuro = '\u001B[33m';
  static const azulEscada  = '\u001B[34m';
  static const cianoJogador = '\u001B[96m';
}

String _corDeTile(Tile t) {
  switch (t) {
    case Tile.chao:          return PaletaTerminal.verdeChao;
    case Tile.paredeFirme:
    case Tile.paredeRachada:
    case Tile.paredeUmida:   return PaletaTerminal.cinzaParede;
    case Tile.escadaSobe:
    case Tile.escadaDesce:   return PaletaTerminal.azulEscada;
    default:                 return PaletaTerminal.reset;
  }
}

String _corDeEntidade(Entidade e) {
  if (e is Inimigo)        return PaletaTerminal.vermelhoInim;
  if (e is EntidadeItem) {
    switch (e.tipo) {
      case TipoItem.ouro:
      case TipoItem.gema:    return PaletaTerminal.amareloOuro;
      default:               return PaletaTerminal.reset;
    }
  }
  return PaletaTerminal.reset;
}

void desenharCharComCor(StringBuffer sb, String char, String cor) {
  sb.write('$cor$char${PaletaTerminal.reset}');
}

String renderizarColorido(MapaMasmorra mapa, Jogador j) {
  var sb = StringBuffer();
  for (var y = 0; y < mapa.altura; y++) {
    for (var x = 0; x < mapa.largura; x++) {
      if (x == j.x && y == j.y) {
        desenharCharComCor(sb, '@', PaletaTerminal.cianoJogador);
        continue;
      }
      var entidade = mapa.entidadeEm(x, y);
      if (entidade != null) {
        desenharCharComCor(sb, entidade.simbolo, _corDeEntidade(entidade));
      } else {
        var t = mapa.grade[y][x];
        desenharCharComCor(sb, tileParaChar(t), _corDeTile(t));
      }
    }
    sb.writeln();
  }
  return sb.toString();
}

A diferença visual de uma renderização com cores vs sem cores é noite e dia. O cérebro processa cor muito mais rápido que forma - inimigo vermelho destaca-se antes do jogador “ler” o O. Variação: tema configurável (PaletaCrt, PaletaSepia, PaletaDaltonica) - cada um com mesmo contrato (corDeTile, corDeEntidade) e o jogador escolhe. Importante para acessibilidade - cerca de 8% dos jogadores têm alguma forma de daltonismo; oferecer paleta acessível inclui mais gente.


Parte IV — O Mercador e a Escada

Capítulo 22 - Economia: Preços, Drops e Balanceamento

Desafio 22.1 - O Tesouro do Dragão Antigo

A lenda diz que um dragão guardava uma Chave Dourada nos tempos antigos. Crie uma nova tabela de drops onde o Dragão tem 5% de chance de deixar cair essa chave rara. Implemente em EntradaSaque com id 'chave_dourada', chance 0.05, quantidade 1, descrição épica. Teste: derrote o dragão 20 vezes, conte quantas vezes recebe a chave. A probabilidade bate com 5%? Dica: use EntradaSaque para encapsular cada possível drop.

Solução de referência. EntradaSaque é uma estrutura imutável “id + chance + quantidade”. O Dragão tem sua própria tabela; cada entrada é rolada independentemente (if (rng.nextDouble() < chance)). Para validar 5%, simular 200-1000 mortes dá variância razoável - 20 mortes pode dar 0 ou 3 e ainda estar dentro do esperado (lei dos grandes números).

class EntradaSaque {
  final String id;
  final double chance;  // 0.0 - 1.0
  final int quantidade;
  final String descricao;

  const EntradaSaque({
    required this.id,
    required this.chance,
    required this.quantidade,
    required this.descricao,
  });
}

const tabelaDragao = [
  EntradaSaque(id: 'ouro', chance: 1.00, quantidade: 500, descricao: 'Pilha de ouro.'),
  EntradaSaque(id: 'gema', chance: 0.50, quantidade: 1, descricao: 'Gema rubi.'),
  EntradaSaque(id: 'chave_dourada', chance: 0.05, quantidade: 1,
               descricao: 'Chave Dourada. Lendária. Abre a câmara mais profunda.'),
];

List<EntradaSaque> resolverDropTabela(List<EntradaSaque> tabela, Random rng) {
  return tabela.where((e) => rng.nextDouble() < e.chance).toList();
}

void main() {
  var rng = Random(42);
  var simulacoes = 1000;
  var contagem = <String, int>{};
  for (var i = 0; i < simulacoes; i++) {
    for (var drop in resolverDropTabela(tabelaDragao, rng)) {
      contagem[drop.id] = (contagem[drop.id] ?? 0) + 1;
    }
  }
  contagem.forEach((id, n) {
    var pct = (n * 100 / simulacoes).toStringAsFixed(1);
    print('$id: $n vezes (${pct}%)');
  });
}

Em 1000 simulações, espere ~50 chaves douradas (5%). Com 20 simulações, o número provável fica entre 0 e 3 - variância alta. Para validar empiricamente, repita a sessão de 20 dragões algumas vezes e tire média. Variação: pity timer - se não dropou em 30 mortes, força o drop. Mecanismo presente em vários jogos comerciais para evitar streaks de azar.

Desafio 22.2 - Ganância do Comerciante

O comerciante da masmorra cobrava margem de 50%. Você descobriu que ele é ganancioso demais. Reduza a margem de venda para 30% mudando kMargemVenda de 0.5 para 0.3. Agora uma Espada de Ferro que custa 50 ouro vale quanto em venda? Calcule manualmente e depois valide em código. Os preços mais justos faz você comprar mais itens?

Solução de referência. “Margem de venda” no contexto do desafio é o que o lojista paga ao comprar do jogador (o jogador “vende”). Margem 0.5 = lojista paga 50% do preço base; 0.3 = paga 30% (mais ganancioso). O nome é traiçoeiro: margem menor é pior para o jogador. Para tornar o jogo mais justo, aumentar para 0.7 faria sentido; o enunciado pediu 0.3, então o exercício é simular essa decisão e medir efeito.

class EconomiaPadrao {
  double kMargemVenda;
  double kMultiplicadorCompra;  // markup ao comprar do lojista

  EconomiaPadrao({
    this.kMargemVenda = 0.5,         // padrão: lojista paga 50%
    this.kMultiplicadorCompra = 1.2,  // padrão: jogador paga 20% acima
  });

  int precoVenda(int precoBase) => (precoBase * kMargemVenda).round();
  int precoCompra(int precoBase) => (precoBase * kMultiplicadorCompra).round();
}

void main() {
  var economiaJusta = EconomiaPadrao(kMargemVenda: 0.5);
  var economiaGanancia = EconomiaPadrao(kMargemVenda: 0.3);

  var precoEspada = 50;
  print('Espada (custo $precoEspada):');
  print('  Padrão:  jogador recebe ${economiaJusta.precoVenda(precoEspada)}');
  print('  Ganância: jogador recebe ${economiaGanancia.precoVenda(precoEspada)}');
}

Saída esperada: 25 vs 15. A diferença de 10 ouro por item, multiplicada por dezenas de transações, transforma a percepção de “loja útil” em “loja não vale a pena”. Variação: margem variável por reputação - vender muito sobe reputação, melhora margem (kMargemVenda += 0.01 por venda, cap em 0.7). Outra: margem por item (consumível = 0.4, raros = 0.6) - lojista paga mais pelo que ele realmente quer.

Desafio 22.3 - A Maldição dos Cinco Andares

Você desce 5 andares, cada um com 3 Lobos hostis. Implemente uma simulação: (1) Faça loop dos andares 0-4, (2) em cada andar, gere 3 Lobos com HP escalonado por getDificuldadeAndar(), (3) resolva drops de cada lobo, (4) some o ouro total. Execute e veja: quantos ouro ganharam? O HP dos lobos aumenta conforme desce? A economia se ajusta naturalmente?

Solução de referência. Simular é a forma rigorosa de avaliar balanceamento. Em vez de “achar” que andar 3 dá 200 ouro, calcule. O cuidado é separar geração de inimigo (cap. 20) de resolução de drops (acima) e escalamento (cap. 18).

class Economia {
  double getDificuldadeAndar(int andar) => 1.0 + andar * 0.5;

  List<EntradaSaque> tabelaLobo = const [
    EntradaSaque(id: 'ouro', chance: 1.0, quantidade: 20, descricao: 'Moedas.'),
    EntradaSaque(id: 'pele', chance: 0.30, quantidade: 1, descricao: 'Pele de lobo.'),
  ];
}

class Lobo extends Inimigo {
  Lobo(int hpBase) : super(nome: 'Lobo', hp: hpBase, maxHp: hpBase, ataque: 4, simbolo: 'L', descricao: '');
}

void simular(int andares, int lobosPorAndar) {
  var rng = Random(42);
  var economia = Economia();
  var ouroTotal = 0;
  var pelesTotal = 0;

  for (var andar = 0; andar < andares; andar++) {
    var dificuldade = economia.getDificuldadeAndar(andar);
    var hpLobo = (10 * dificuldade).round();
    print('--- Andar $andar (dificuldade ${dificuldade.toStringAsFixed(1)}) ---');
    var ouroAndar = 0;
    for (var i = 0; i < lobosPorAndar; i++) {
      var lobo = Lobo(hpLobo);
      print('  Lobo #${i+1}: HP=${lobo.hp}');
      var drops = resolverDropTabela(economia.tabelaLobo, rng);
      for (var d in drops) {
        if (d.id == 'ouro') ouroAndar += d.quantidade;
        if (d.id == 'pele') pelesTotal++;
      }
    }
    print('  Ouro do andar: $ouroAndar');
    ouroTotal += ouroAndar;
  }

  print('=== TOTAL ===');
  print('Ouro acumulado: $ouroTotal');
  print('Peles: $pelesTotal');
}

void main() => simular(5, 3);

A simulação revela: 5 andares × 3 lobos × 20 ouro = 300 ouro garantido + ~15 ouro de peles vendidas. Compare com preço de uma poção (50 ouro) - dá para comprar 6, o que parece equilibrado. Variação: rodar 100 simulações e plotar curva (ouro acumulado por andar) - ferramenta crítica para balancing real. Output em CSV (andar,ouro,peles\n0,60,1) facilita análise em Excel/Python.

Desafio 22.4 - Modo Fácil para Aprendizes

Criar um jogo que escala dificuldade é difícil. Você quer testar em modo fácil onde tudo é menos letal. Crie EconomiaFacil extends Economia: dificuldade em 50% (inimigos mais fracos), drops em 150% (mais ouro). Simule 5 andares em modo fácil e modo normal, compare. Em fácil, sobrevive melhor? Ganha mais ouro?

Solução de referência. Subclassear Economia para EconomiaFacil é a aplicação cristalina do template method - a estrutura é a mesma; só os parâmetros mudam. O ganho: passar EconomiaFacil() ou Economia() para o jogo sem mudar uma linha do gameplay.

class EconomiaFacil extends Economia {
  @override
  double getDificuldadeAndar(int andar) {
    return super.getDificuldadeAndar(andar) * 0.5;
  }

  List<EntradaSaque> get tabelaLoboBonus {
    // multiplica quantidade de ouro por 1.5
    return tabelaLobo.map((e) {
      if (e.id == 'ouro') {
        return EntradaSaque(
          id: e.id, chance: e.chance,
          quantidade: (e.quantidade * 1.5).round(),
          descricao: e.descricao,
        );
      }
      return e;
    }).toList();
  }
}

void main() {
  print('=== MODO NORMAL ===');
  simularComEconomia(Economia());
  print('=== MODO FÁCIL ===');
  simularComEconomia(EconomiaFacil());
}

A subclasse pode também sobrescrever tabelaLobo direto (em vez de criar tabelaLoboBonus) - mais ergonômico. A diferença é manter a tabela original imutável (clean), ou mutável (simples). Para o livro, simples ganha. Variação: cinco modos (Tutorial, Fácil, Normal, Difícil, Pesadelo) com presets diferentes (dificuldade *= 2.0 em Pesadelo). Outra: modo customizável com sliders (UserConfig.dificuldade = 1.3) - permite tunning fino sem novos modos.

Desafio 22.5 - Raríssimo

Crie um item extremamente raro: 0.1% de chance, mas que vale 10000 ouro. Simule 10000 mortes de inimigos e verifique: estatisticamente, recebe esse item ~10 vezes (0.1% de 10000).

Solução de referência. Lei dos grandes números: chance de 0.1% sobre 10000 tentativas dá média de 10 acertos, mas variância significativa. O ponto pedagógico: distribuição binomial.

void main() {
  const chance = 0.001;
  const tentativas = 10000;
  var rng = Random(42);

  var acertos = 0;
  for (var i = 0; i < tentativas; i++) {
    if (rng.nextDouble() < chance) acertos++;
  }

  var esperado = (chance * tentativas).round();
  print('Tentativas: $tentativas');
  print('Chance por tentativa: ${chance * 100}%');
  print('Acertos esperados: $esperado');
  print('Acertos observados: $acertos');
  print('Erro relativo: ${(((acertos - esperado) / esperado) * 100).toStringAsFixed(1)}%');
}

Com seed 42, pode dar algo como 9, 11, 14 - todos dentro do “esperado” estatístico (desvio-padrão ≈ √(npq) ≈ 3.16). Variação: rodar 100 vezes a simulação inteira (for var trial in 1..100) e plotar histograma do acertos por trial - revela a forma da distribuição binomial. Em código, manter contagem num Map<int, int> ({ 7: 3, 8: 12, 9: 17, ... }) e imprimir como gráfico ASCII ('█' * contagem[v]).

Boss Final 22.6 - Loja Dinâmica com Estoque

Crie uma Loja que carrega tabela de preços e estoque inicial. Implementa: (a) compra (jogador paga preço, item entra inventário), (b) venda (item sai inventário, jogador recebe margem). Inicialmente lojista tem 100 ouro; ele não pode comprar se ficar negativo. Tente comprar 5 itens, vender 3 - imprima o ouro do lojista e jogador no fim.

Solução de referência. Loja com ouro próprio é o detalhe profissional. Lojistas pobres não compram tudo; jogadores espertos esgotam o caixa. Cria escassez interessante. A função lojistaComprarDe valida ouro do lojista antes; se insuficiente, recusa.

class LojaDinamica {
  int ouro;
  final Economia economia;
  final List<EntradaItemLoja> estoque;

  LojaDinamica({
    required this.ouro,
    required this.economia,
    required this.estoque,
  });

  bool jogadorComprar(Jogador j, int indice) {
    if (indice < 0 || indice >= estoque.length) return false;
    var entrada = estoque[indice];
    if (entrada.quantidade <= 0) {
      print('Estoque vazio: ${entrada.item.nome}.');
      return false;
    }
    var preco = economia.precoCompra(entrada.precoBase);
    if (j.ouro < preco) {
      print('Você não tem ouro suficiente.');
      return false;
    }
    j.ouro -= preco;
    ouro += preco;
    j.inventario.add(entrada.item);
    entrada.quantidade--;
    print('Comprado ${entrada.item.nome} por $preco. (seu ouro: ${j.ouro}, loja: $ouro)');
    return true;
  }

  bool jogadorVender(Jogador j, int indiceInv) {
    if (indiceInv < 0 || indiceInv >= j.inventario.length) return false;
    var item = j.inventario[indiceInv];
    var preco = economia.precoVenda(item.precoBase);
    if (ouro < preco) {
      print('A loja não tem caixa para pagar $preco. (loja: $ouro)');
      return false;
    }
    ouro -= preco;
    j.ouro += preco;
    j.inventario.removeAt(indiceInv);
    estoque.add(EntradaItemLoja(item: item, precoBase: item.precoBase, quantidade: 1));
    print('Vendido ${item.nome} por $preco. (seu ouro: ${j.ouro}, loja: $ouro)');
    return true;
  }
}

class EntradaItemLoja {
  Item item;
  int precoBase;
  int quantidade;
  EntradaItemLoja({required this.item, required this.precoBase, required this.quantidade});
}

O ouro da loja flutua: jogador compra → ouro entra na loja; jogador vende → ouro sai. Loja prospera com mercadoria desejada, falha com excesso de itens caros. Variação: cap de estoque (máximo 20 itens armazenáveis) - força jogador a vender só o melhor. Outra: estoque renovado a cada N turnos (lojista compra de “fornecedores invisíveis”). Para roguelike puro, lojas em andares específicos com tabelas diferentes - “andar 5: lojista de armas” tem armas baratas; “andar 10: feiticeiro” vende poções caras.


Capítulo 23 - A Loja do Mercador: UI e Fluxo

Desafio 23.1 - Estoque Rotativo: Itens Novos a Cada Andar

Implemente um método regenerarEstoque() na loja que troca parte dos itens a cada andar. Use import 'dart:math' e Random().nextInt() para selecionar 2-3 itens novos de uma lista maior de possibilidades.

Solução de referência. Estoque rotativo é hook narrativo: “chegou mercadoria nova” dá razão para o jogador voltar à loja. Tecnicamente, é shuffle parcial - remove alguns, sorteia outros da pool. Manter uma lista “catálogo” (todos os possíveis) separada da “atual” (o que o lojista expõe) deixa o sistema testável.

class Loja {
  final List<ItemVenda> _catalogo;  // pool completa
  List<ItemVenda> itensAtuais = [];

  Loja(this._catalogo);

  void regenerarEstoque(Random rng, {int quantidadeNova = 3}) {
    // remove até 3 antigos aleatoriamente
    itensAtuais.shuffle(rng);
    if (itensAtuais.length > 5) {
      itensAtuais = itensAtuais.sublist(0, itensAtuais.length - quantidadeNova);
    }

    // adiciona novos do catálogo
    var disponiveis = _catalogo.where((c) => !itensAtuais.contains(c)).toList();
    disponiveis.shuffle(rng);
    for (var i = 0; i < quantidadeNova && i < disponiveis.length; i++) {
      itensAtuais.add(disponiveis[i]);
    }

    print('Mercador: "Chegou mercadoria nova! Dá uma olhada."');
  }
}

shuffle da lista é in-place - se quiser preservar a original, use [...itens]..shuffle(). Variação: cada item tem peso (probabilidade relativa de aparecer); itens raros raramente entram no estoque. Implementa-se com weighted random (cap. 17 desafio 17.2). Outra: estoque persistente entre execuções (save salva itensAtuais).

Desafio 23.2 - A Chave do Final

No coração da loja aparece um item lendário: a Chave Dourada que abre a porta do boss final. Crie um ItemVenda com nome “Chave Dourada Rara”, id 'chave_dourada', preço 500 ouro, estoque 1.

Solução de referência. Itens “chave de progressão” diferem de equipamento: você compra para destrancar conteúdo, não para combater. Mantê-los caros e únicos cria meta-objetivo - economizar para o item certo.

class ItemVenda {
  final String nome;
  final String id;
  final int preco;
  final String descricao;
  int estoque;
  bool raro;

  ItemVenda({
    required this.nome,
    required this.id,
    required this.preco,
    required this.descricao,
    this.estoque = 1,
    this.raro = false,
  });
}

List<ItemVenda> inventarioBase() {
  return [
    ItemVenda(nome: 'Poção Pequena', id: 'pocao_pequena', preco: 30, descricao: '+10 HP', estoque: 5),
    ItemVenda(nome: 'Poção Média', id: 'pocao_media', preco: 80, descricao: '+25 HP', estoque: 3),
    ItemVenda(
      nome: 'Chave Dourada Rara',
      id: 'chave_dourada',
      preco: 500,
      descricao: 'Abre a porta do boss final. Lendária.',
      estoque: 1,
      raro: true,
    ),
  ];
}

A flag raro será usada no Boss 23.7 para lógica de restoque especial. Variação: chave que só aparece depois que o jogador derrotou certo número de inimigos - introduz progressão temporal além da espacial. Implementa-se com gatilho no Observer (cap. 26): “atingiu 50 inimigos derrotados → adiciona Chave ao estoque do próximo andar”.

Desafio 23.3 - O Roubo do Comerciante

Você negocia com o comerciante: uma Espada de Aço de 75 ouro em compra. Quanto ele oferece quando você quer vender de volta? Calcule manualmente (resposta: 37.5 ouro com margem 50%, ou 22.5 com margem 30%). Depois implemente no código e valide.

Solução de referência. O “roubo” do comerciante é a margem que ele tira em cada transação. Visualizar isso explicitamente educa o jogador (e o programador): cada compra-venda perde dinheiro. A “perda total” em ciclo completo (compra → venda) é precoCompra - precoVenda = preco × (markup - margem).

class Economia {
  final double markup;   // multiplicador de compra
  final double margem;   // multiplicador de venda

  const Economia({this.markup = 1.0, this.margem = 0.5});

  int precoCompra(int base) => (base * markup).round();
  int precoVenda(int base) => (base * margem).round();
  int perdaEmCiclo(int base) => precoCompra(base) - precoVenda(base);
}

void main() {
  const precoBase = 75;
  for (var margem in [0.5, 0.3]) {
    var e = Economia(markup: 1.0, margem: margem);
    var perda = e.perdaEmCiclo(precoBase);
    print('Margem ${margem}: '
        'compra ${e.precoCompra(precoBase)}, '
        'venda ${e.precoVenda(precoBase)}, '
        'perda em ciclo = $perda');
  }
}

Saída: Margem 0.5: compra 75, venda 38, perda em ciclo = 37 e Margem 0.3: compra 75, venda 22, perda em ciclo = 53. Visualizar a perda em ouro absoluto torna a economia tangível. Variação: simular jogador que compra-vende-compra-vende-compra 10x e mostrar quanto ouro derrete - lição econômica vívida.

Desafio 23.4 - O Tesouro da Profundeza

Conforme você desce muito fundo (andar 10 e além), a loja recebe artefatos lendários. Crie um método _inventarioAndarProfundo() que retorna itens épicos: “Espada Ancestral” (+10 ataque, 5000 ouro), “Anel de Imortalidade” (impede morte uma vez, 8000 ouro), “Tomo de Poder” (+5 ao nível, 6000 ouro).

Solução de referência. Inventário condicional ao andar enriquece progressão - novos andares trazem novas tentações. O método _inventarioAndarProfundo() é puro (retorna lista, sem efeitos); restoquear(andar) decide qual conjunto incluir.

List<ItemVenda> _inventarioAndarProfundo() => [
  ItemVenda(nome: 'Espada Ancestral', id: 'esp_ancestral',
            preco: 5000, descricao: '+10 ataque permanente.', estoque: 1, raro: true),
  ItemVenda(nome: 'Anel de Imortalidade', id: 'anel_imortal',
            preco: 8000, descricao: 'Impede morte uma vez.', estoque: 1, raro: true),
  ItemVenda(nome: 'Tomo de Poder', id: 'tomo_poder',
            preco: 6000, descricao: '+5 níveis instantâneos.', estoque: 1, raro: true),
];

void restoquear(Loja loja, int andar, Random rng) {
  loja.itensAtuais = [...inventarioBase()];
  if (andar >= 10) {
    loja.itensAtuais.addAll(_inventarioAndarProfundo());
  }
  loja.regenerarEstoque(rng, quantidadeNova: 1);
  print('Estoque atualizado para o andar $andar.');
}

Os preços 5k-8k vs ouro acumulado em runs típicos (~3-4k até andar 10) criam decisão de “guardar muito” ou “spendir já”. Variação: itens escaláveis por andar - “Espada Ancestral +N” onde N = andar - 9; aí mesmo no andar 30, vale a pena comprar de novo (versão melhorada). Outra: preço crescente com andar (preco = baseDoItem * (1 + andar * 0.1)) - itens ficam mais caros, mas o jogador também acumula mais.

Desafio 23.5 - Loja Segura com Exceções

A loja não pode quebrar. Se você não tem ouro, lança exceção, não trava. Crie LojaExcecao, OuroInsuficienteExcecao, MochilaCheiaExcecao. Refatore Mercador.comprar() para verificar e lançar exceções ao invés de retornar strings de erro.

Solução de referência. Trocar bool ou strings por exceções é mudança idiomática em Dart: erro vira tipo, dá para catch especializado, e o “caminho feliz” do código fica limpo. Hierarquia: classe base LojaExcecao, e subclasses para casos específicos.

sealed class LojaExcecao implements Exception {
  final String mensagem;
  const LojaExcecao(this.mensagem);
  @override
  String toString() => mensagem;
}

class OuroInsuficienteExcecao extends LojaExcecao {
  final int faltam;
  const OuroInsuficienteExcecao(this.faltam)
      : super('Faltam \$faltam de ouro.');
}

class MochilaCheiaExcecao extends LojaExcecao {
  const MochilaCheiaExcecao() : super('Sua mochila está cheia.');
}

class ItemEsgotadoExcecao extends LojaExcecao {
  final String nomeItem;
  const ItemEsgotadoExcecao(this.nomeItem)
      : super('$nomeItem esgotou.');
}

class Mercador {
  void comprar(Jogador j, ItemVenda item) {
    if (item.estoque <= 0) throw ItemEsgotadoExcecao(item.nome);
    if (j.ouro < item.preco) throw OuroInsuficienteExcecao(item.preco - j.ouro);
    if (j.inventario.length >= j.capacidadeMaxima) throw MochilaCheiaExcecao();

    j.ouro -= item.preco;
    j.inventario.add(item);
    item.estoque--;
  }
}

// uso na UI:
void comandoComprarUi(Jogador j, ItemVenda item, Mercador m) {
  try {
    m.comprar(j, item);
    print('Comprado: ${item.nome}.');
  } on OuroInsuficienteExcecao catch (e) {
    print('Não tem ouro. ${e.mensagem}');
  } on MochilaCheiaExcecao {
    print('Mochila lotada. Largue algo primeiro.');
  } on LojaExcecao catch (e) {
    print('Erro: $e');
  }
}

sealed class LojaExcecao no Dart 3 deixa o compilador validar que catch (e) cobre todas as variantes (em switch). on no catch filtra por tipo - mais legível que catch (e) { if (e is X) ... }. Variação: incluir contexto extra na exceção (OuroInsuficienteExcecao(faltam, nomeItem)) - mensagens “Faltam 50 ouro para Espada Longa” são mais úteis que “Faltam 50 ouro”.

Desafio 23.6 - Ofertas do Dia

Todo dia, a loja tem 3 itens especiais em destaque com 50% de desconto. Use DateTime.now() para pegar a data e criar seed determinística (ex: seed = DateTime.now().year * 10000 + DateTime.now().month * 100 + DateTime.now().day).

Solução de referência. Daily deals incentivam retorno: “se eu jogar hoje, vejo esta oferta”. A seed determinística por data faz com que o mesmo dia produza as mesmas ofertas em qualquer máquina - útil para comunidade (“vocês viram a oferta de hoje?”).

int seedDoDia([DateTime? quando]) {
  quando ??= DateTime.now();
  return quando.year * 10000 + quando.month * 100 + quando.day;
}

class OfertaDia {
  final ItemVenda item;
  final double desconto;  // 0.5 = metade do preço
  OfertaDia(this.item, this.desconto);

  int get precoComDesconto => (item.preco * (1 - desconto)).round();
}

List<OfertaDia> gerarOfertasDoDia(List<ItemVenda> catalogo) {
  var rng = Random(seedDoDia());
  var copia = [...catalogo]..shuffle(rng);
  return copia.take(3).map((i) => OfertaDia(i, 0.5)).toList();
}

void main() {
  var catalogo = inventarioBase();
  var ofertas = gerarOfertasDoDia(catalogo);
  print('=== Ofertas do Dia (${DateTime.now().toIso8601String().substring(0, 10)}) ===');
  for (var o in ofertas) {
    print('  ${o.item.nome}: ~~${o.item.preco}~~ ${o.precoComDesconto} (-${(o.desconto * 100).toInt()}%)');
  }
}

A escolha year * 10000 + month * 100 + day (em vez de DateTime.now().millisecondsSinceEpoch ~/ 86400000) é mais legível e estável quanto a timezones. Variação: ofertas semanais (seedDoSemana = ano * 100 + semanaDoAno) com descontos maiores (75%) mas atualizadas só de domingo. Outra: histórico de ofertas (ofertasUltimos7Dias) para o jogador planejar.

Boss Final 23.7 - Itens Únicos e Valiosos

Itens lendários não devem estar sempre em estoque. Implemente: itens marcados como “raro=true” têm estoque máximo 1 por andar. Após vender, volta a 1 no próximo andar. Teste: compre a “Espada Ancestral” do andar 10, vá para andar 11, retorne ao 10, item deve estar de novo disponível.

Solução de referência. A regra “raros = 1 por andar, restoca no próximo” criar tensão “agora ou depois”. Não tem urgência absoluta (vai voltar), mas tem urgência relativa (precisa ouro acumulado agora). Tecnicamente: separar restoque de raros do restoque normal.

class LojaPorAndar {
  final Map<int, List<ItemVenda>> _estoquePorAndar = {};
  final List<ItemVenda> catalogoNormal;
  final List<ItemVenda> catalogoRaro;

  LojaPorAndar({required this.catalogoNormal, required this.catalogoRaro});

  List<ItemVenda> obterEstoque(int andar) {
    if (!_estoquePorAndar.containsKey(andar)) {
      // primeira visita ao andar: gera estoque
      var lista = [
        // normais com estoque cheio
        ...catalogoNormal.map((i) => ItemVenda(
              nome: i.nome, id: i.id, preco: i.preco, descricao: i.descricao,
              estoque: i.estoque,
            )),
        // raros com estoque 1
        if (andar >= 10) ...catalogoRaro.map((i) => ItemVenda(
              nome: i.nome, id: i.id, preco: i.preco, descricao: i.descricao,
              estoque: 1, raro: true,
            )),
      ];
      _estoquePorAndar[andar] = lista;
    }
    return _estoquePorAndar[andar]!;
  }

  void aoAvancarAndar(int novoAndar) {
    // não toca nos andares anteriores; o estoque do andar 10 fica como deixou.
    // Se quiser "regenerar raros ao voltar", limpe a chave do andar antigo aqui.
  }
}

A escolha de persistir o estoque por andar (em Map<int, ...>) versus regenerar ao revisitar é decisão de design. O enunciado pede regeneração - aí, ao revisitar andar 10, descarta o cache e cria novo estoque. O código fica:

List<ItemVenda> obterEstoque(int andar) {
  // SEMPRE regenera: cada visita ao andar reaparece a raridade
  _estoquePorAndar[andar] = [
    ...catalogoNormal.map(...),
    if (andar >= 10) ...catalogoRaro.map((i) => i..estoque = 1),
  ];
  return _estoquePorAndar[andar]!;
}

Esse é o efeito “voltar ao andar 10 → Espada Ancestral disponível de novo”. Variação: limitar quantas vezes pode ser comprada no total (não importa quantas vezes visitar o andar, só vai vender 3 vezes na campanha inteira). Mais escasso, mais especial. Tracking via int totalCompradoNaCampanha no item.


Capítulo 24 - Generics e Pattern Matching: Sistema de Eventos

Desafio 24.1 - Quando o Guerreiro Muda de Arma

Seu guerreiro equipa uma Espada Lendária, depois a desequipa para voltar ao escudo. Cada mudança conta. Crie EventoEquipamento extends EventoJogo com itemId, nomeItem, equipado (bool). Dispare esse evento toda vez que equipar/desequipar.

Solução de referência. sealed class para hierarquia de eventos garante que o switch no processador é exaustivo - adicionar EventoMaldicao daqui a 10 capítulos quebra o compilador (você sabe onde ajustar). Cada evento carrega só os dados que precisa; nenhum precisa de tipo: 'equipamento' string mágica.

sealed class EventoJogo {
  final DateTime timestamp;
  EventoJogo() : timestamp = DateTime.now();
}

class EventoEquipamento extends EventoJogo {
  final String itemId;
  final String nomeItem;
  final bool equipado;
  EventoEquipamento({required this.itemId, required this.nomeItem, required this.equipado});
}

class EventoCombate extends EventoJogo {
  final String alvo;
  final int dano;
  EventoCombate({required this.alvo, required this.dano});
}

class BarramentoEventos {
  final List<EventoJogo> _historico = [];
  void publicar(EventoJogo e) {
    _historico.add(e);
  }
  List<EventoJogo> get todos => List.unmodifiable(_historico);
}

extension EquipUiJogador on Jogador {
  void equipar(ItemVenda item, BarramentoEventos barramento) {
    armaEquipada = item;
    barramento.publicar(EventoEquipamento(
      itemId: item.id, nomeItem: item.nome, equipado: true,
    ));
  }
  void desequipar(BarramentoEventos barramento) {
    if (armaEquipada == null) return;
    var item = armaEquipada!;
    armaEquipada = null;
    barramento.publicar(EventoEquipamento(
      itemId: item.id, nomeItem: item.nome, equipado: false,
    ));
  }
}

Após 3 equipar + 3 desequipar = 6 eventos. O BarramentoEventos é central - todos os subsistemas (UI, save, conquistas) leem dele em vez de cada um observar o jogador diretamente. Isso é o gérmen do Mediator do cap. 27. Variação: cada evento ganha id autoincremental para correlacionar com replays.

Desafio 24.2 - Narrativa do Equipamento

O log de ações deve refletir sua jornada de equipamento. Adicione um case em ProcessadorEventos.renderizar(): se EventoEquipamento com equipado=true, exiba em verde [EQUIP] Equipaste: Espada Lendária. Se equipado=false, em vermelho [DESQUIP] Desequipaste:....

Solução de referência. Pattern matching com guard (when) brilha aqui - um único case decide o output baseado num campo do objeto. Sem when, você precisa de dois cases iguais com if dentro; com when, fica declarativo.

class ProcessadorEventos {
  String renderizar(EventoJogo e) {
    switch (e) {
      case EventoEquipamento(:final nomeItem, :final equipado) when equipado:
        return '\u001B[32m[EQUIP] Equipaste: $nomeItem\u001B[0m';
      case EventoEquipamento(:final nomeItem):
        return '\u001B[31m[DESQUIP] Desequipaste: $nomeItem\u001B[0m';
      case EventoCombate(:final alvo, :final dano):
        return '[ATK] $dano em $alvo';
    }
  }
}

void main() {
  var p = ProcessadorEventos();
  var e1 = EventoEquipamento(itemId: 'esp1', nomeItem: 'Espada Lendária', equipado: true);
  var e2 = EventoEquipamento(itemId: 'esp1', nomeItem: 'Espada Lendária', equipado: false);
  print(p.renderizar(e1));  // verde
  print(p.renderizar(e2));  // vermelho
}

O case EventoEquipamento(:final nomeItem, :final equipado) when equipado lê quase em português: “se for evento de equipamento E equipou”. O segundo case sem when pega “todo o resto de EventoEquipamento” - desequipou. Variação: o [EQUIP] antes do verbo é deixa para parsers automáticos do log (futuros achievements). Mantenha esses prefixos consistentes - tornam o log meio “estruturado”, e o cap. 25 pode minerar.

Desafio 24.3 - Combate Violento

Nem todo dano é importante. Filtre eventos de combate: retorne só EventoCombate com dano > 20 (golpes críticos e devastadores). Itere e exiba: “Crítico! Dano: 35”. Teste: em 100 turnos de combate, quantos golpes foram >= 20 dano?

Solução de referência. whereType<EventoCombate>() filtra por tipo (e refina o tipo estaticamente); .where((e) => e.dano > 20) filtra pelo campo. Combinação clássica e ergonômica.

List<EventoCombate> golpesPesados(BarramentoEventos b, {int limiar = 20}) {
  return b.todos
      .whereType<EventoCombate>()
      .where((e) => e.dano > limiar)
      .toList();
}

void main() {
  var b = BarramentoEventos();
  var rng = Random(42);
  for (var i = 0; i < 100; i++) {
    var dano = rng.nextInt(40) + 1;  // 1-40
    b.publicar(EventoCombate(alvo: 'Orc#$i', dano: dano));
  }

  var fortes = golpesPesados(b);
  print('Total de golpes: 100');
  print('Golpes >20: ${fortes.length}');
  for (var g in fortes.take(5)) {
    print('  Crítico! Dano: ${g.dano} em ${g.alvo}');
  }
}

Com nextInt(40) + 1 uniformemente, ~50% serão > 20 → esperar 50 críticos. Para distribuição mais realista (bell curve com dano médio 10, raros próximos a 40), use soma de dois nextInt(20) - distribuição triangular. Variação: contagem por intervalo - “<10: 30, 10-19: 40, 20-29: 20, 30+: 10”. Iterable.fold ou groupBy (do package collection) constroem o histograma.

Desafio 24.4 - Resumo da Partida

Ao fim do jogo, você quer saber: Quantas vezes equipou itens? Quantas vezes sofreu dano? Quantas compras na loja? Implemente contagemPorTipo() que retorna um mapa: {'EventoCombate': 145, 'EventoEquipamento': 8, 'EventoCompra': 3}.

Solução de referência. runtimeType retorna o tipo concreto de qualquer objeto - usar como chave de Map<Type, int> (ou Map<String, int>) torna a contagem trivial.

Map<String, int> contagemPorTipo(BarramentoEventos b) {
  var contagem = <String, int>{};
  for (var e in b.todos) {
    var nome = e.runtimeType.toString();
    contagem[nome] = (contagem[nome] ?? 0) + 1;
  }
  return contagem;
}

void exibirResumo(BarramentoEventos b) {
  print('=== RESUMO DA PARTIDA ===');
  var c = contagemPorTipo(b);
  c.forEach((tipo, qtd) => print('  $tipo: $qtd'));
  print('  TOTAL: ${b.todos.length}');
}

void main() {
  var b = BarramentoEventos();
  b.publicar(EventoCombate(alvo: 'Orc', dano: 5));
  b.publicar(EventoCombate(alvo: 'Goblin', dano: 8));
  b.publicar(EventoEquipamento(itemId: 'esp1', nomeItem: 'Espada', equipado: true));
  exibirResumo(b);
}

Variação 1: usar switch com pattern matching em vez de runtimeType.toString() - mais robusto (refatorar nome de classe não quebra a contagem). Variação 2: contagem aninhada por subtipo - “EventoCompra > poção: 5, espada: 2”. Para análise pós-partida em planilha, exporte como CSV: contagem.entries.map((e) => '${e.key},${e.value}').join('\n').

Desafio 24.5 - Assista a Sua Epopeia

Você quer mostrar a um amigo o que aconteceu na masmorra. Implemente EventReplay que armazena eventos e tem método async tocar(): exibe cada evento com 500ms entre eles.

Solução de referência. Reprodução assíncrona com Future.delayed é o jeito Dart de “esperar sem travar”. O método tocar é async, e cada await Future.delayed(...) pausa só essa função - resto do programa continua. Para CLI single-thread, isso basicamente serializa eventos com pausa entre eles.

class EventReplay {
  final List<EventoJogo> eventos;
  final ProcessadorEventos processador;

  EventReplay(this.eventos, this.processador);

  Future<void> tocar({Duration intervalo = const Duration(milliseconds: 500)}) async {
    print('--- INÍCIO DO REPLAY ---');
    for (var i = 0; i < eventos.length; i++) {
      var e = eventos[i];
      print('[${i + 1}/${eventos.length}] ${processador.renderizar(e)}');
      await Future.delayed(intervalo);
    }
    print('--- FIM DO REPLAY ---');
  }
}

void main() async {
  var b = BarramentoEventos();
  for (var i = 0; i < 50; i++) {
    b.publicar(EventoCombate(alvo: 'Orc#$i', dano: 5 + i % 30));
  }

  var replay = EventReplay(b.todos, ProcessadorEventos());
  await replay.tocar(intervalo: Duration(milliseconds: 200));
}

main precisa ser async para aguardar o replay. Variação: velocidade ajustável em tempo real (atalhos +/- para acelerar/desacelerar) - precisa de Stream ou polling em vez do await Future.delayed. Outra: “pulinhos” - se o usuário pressionar Enter, salta para o próximo evento sem esperar. Para CLI no Dart, ler input async é menos trivial; alternativa é dart:ffi ou cli_completion package.

Boss Final 24.6 - Combate Recente

Você quer saber: Nos últimos 5 minutos de jogo, qual foi o dano total sofrido? Implemente um método que retorna eventos de combate ocorridos nos últimos N minutos. Use DateTime.now() e evento.timestamp.difference(DateTime.now()).inMinutes < N.

Solução de referência. Filtros por janela temporal são úteis para HUDs (“últimos 5 minutos”) e análises (“intensidade do combate”). O Duration.difference(DateTime) retorna Duration; .inMinutes extrai inteiros. Como timestamp é no passado, now.difference(timestamp) é positivo.

List<EventoCombate> combateRecente(BarramentoEventos b, int minutos) {
  var agora = DateTime.now();
  return b.todos
      .whereType<EventoCombate>()
      .where((e) => agora.difference(e.timestamp).inMinutes < minutos)
      .toList();
}

int danoSofridoUltimosMinutos(BarramentoEventos b, int minutos) {
  return combateRecente(b, minutos).fold<int>(0, (s, e) => s + e.dano);
}

void main() async {
  var b = BarramentoEventos();

  b.publicar(EventoCombate(alvo: 'Goblin', dano: 5));
  await Future.delayed(Duration(seconds: 2));  // simula tempo
  b.publicar(EventoCombate(alvo: 'Orc', dano: 12));
  await Future.delayed(Duration(seconds: 3));
  b.publicar(EventoCombate(alvo: 'Dragão', dano: 30));

  print('Dano últimos 5 min: ${danoSofridoUltimosMinutos(b, 5)}');  // 47
  print('Dano último 1 min: ${danoSofridoUltimosMinutos(b, 1)}');   // 30
}

fold<int>(0, ...) indica explicitamente o tipo do acumulador - sem isso, Dart inferia o tipo a partir do primeiro acumulador mais geral. Variação: HUD “intensidade” - se dano nos últimos 30 segundos > 50, exibe ícone vermelho pulsante. Outra: alerta de morte iminente - se ritmo de dano (damage / minute) projeta morte em <2 turnos, sugere fuga. Análise temporal vira “consciência tática” da IA do jogo.


Capítulo 25 - Progressão: XP, Níveis e Habilidades

Desafio 25.1 - A Escalada Interminável

Conforme você sobe de nível, custa cada vez mais. Mude a fórmula: em vez de n² × 50, use n³ × 10 (cúbica).

Solução de referência. Curvas de XP definem o “feeling” da progressão. Quadrática (n² × 50) cresce rápido mas estabiliza; cúbica (n³ × 10) acelera sem freio. Para roguelike onde nível 20+ é raro, cúbica recompensa quem persiste sem trivializar os primeiros níveis.

class Progressao {
  int xpNecessarioParaNivel(int nivel) {
    return nivel * nivel * nivel * 10;
  }

  int xpAcumuladoAteNivel(int nivel) {
    var total = 0;
    for (var n = 1; n <= nivel; n++) {
      total += xpNecessarioParaNivel(n);
    }
    return total;
  }
}

void main() {
  var p = Progressao();
  print('Nivel | XP no nível | XP acumulado');
  for (var n = 1; n <= 10; n++) {
    print('  $n   | ${p.xpNecessarioParaNivel(n).toString().padLeft(8)} | ${p.xpAcumuladoAteNivel(n)}');
  }
}

Saída comparativa - nível 3 cúbico = 270 (vs 450 da quadrática); nível 10 cúbico = 10000 (vs 5000). Em níveis altos, cúbica é dramática. Variação: híbrido (linear até nível 5, depois cúbica) para “ganchar” iniciantes. Outra: tabela explícita (tabela[1] = 100, tabela[2] = 250...) - mais controle, mais boring. Cubo é compacto e expressivo.

Desafio 25.2 - Três Caminhos do Guerreiro

Você pode treinar para ser recruta (rápido), normal (balanceado), ou veterano (lento mas forte). Crie enum Dificuldade { recruta, normal, veterano } e campo em Jogador.

Solução de referência. Multiplicador no XP é o jeito mais simples de “modos” - mesma mecânica, ajuste numérico. Compensar a lentidão do veterano com bônus de stats é o que torna o trade-off interessante.

enum Dificuldade {
  recruta(multXp: 1.5, multHp: 0.8, multAtaque: 0.8),
  normal(multXp: 1.0, multHp: 1.0, multAtaque: 1.0),
  veterano(multXp: 0.5, multHp: 1.5, multAtaque: 1.5);

  final double multXp, multHp, multAtaque;
  const Dificuldade({required this.multXp, required this.multHp, required this.multAtaque});
}

class Jogador {
  Dificuldade dificuldade;
  int xp = 0;
  int nivel = 1;
  int hp, maxHp, ataque;

  Jogador(this.dificuldade)
      : hp = (100 * dificuldade.multHp).round(),
        maxHp = (100 * dificuldade.multHp).round(),
        ataque = (5 * dificuldade.multAtaque).round();

  void ganharXp(int valor) {
    var efetivo = (valor * dificuldade.multXp).round();
    xp += efetivo;
    print('+$efetivo XP (${dificuldade.name}, multiplier ${dificuldade.multXp})');
  }
}

void main() {
  for (var d in Dificuldade.values) {
    var j = Jogador(d);
    j.ganharXp(100);
    print('${d.name}: HP=${j.maxHp}, ATK=${j.ataque}, XP=${j.xp}');
  }
}

Recruta sobe rápido mas tem HP/ataque baixos; veterano apanha menos (50% mais HP) mas demora pra subir. Variação: mais dimensões (multDrops, multInimigosNumero) tornando modos mais distintos. Outra: “Dificuldade adaptativa” - jogo aumenta multiplicador automaticamente se o jogador morre muito.

Desafio 25.3 - Barra de Progresso Épica

Você quer saber exatamente onde está na progressão. Implemente mostrarProgressoDetalhado(): “Nível 4 ████████░░ 80% | 240/300 XP”.

Solução de referência. Reuso da barra do cap. 6 (desafio 6.2), agora alimentada por dados do Jogador. A diferença é o contexto: aqui é nível, não HP - portanto cor diferente, símbolo diferente, e o número conta “rumo ao próximo” em vez de “perdendo”.

String mostrarProgressoDetalhado(Jogador j) {
  var progressao = Progressao();
  var xpProximo = progressao.xpNecessarioParaNivel(j.nivel + 1);
  var razao = j.xp / xpProximo;
  if (razao > 1) razao = 1;

  var largura = 10;
  var cheias = (razao * largura).round();
  var vazias = largura - cheias;
  var barra = '█' * cheias + '░' * vazias;
  var pct = (razao * 100).toInt();

  return 'Nível ${j.nivel} $barra $pct% | ${j.xp}/$xpProximo XP';
}

void main() {
  var j = Jogador(Dificuldade.normal)..nivel = 4..xp = 240;
  print(mostrarProgressoDetalhado(j));
}

Saída: Nível 4 ████████░░ 80% | 240/300 XP. Variação: barra colorida pelo nível (níveis 1-5 verdes, 6-10 amarelos, 11+ vermelhos - “perigo, você está chegando ao topo”). Outra: animação de XP que cresce - quando ganha 100 XP, mostra barra preenchendo gradualmente em vez de pulando direto. Loop curto com sleep(20ms).

Desafio 25.4 - O Paladim Nível 10

Ao atingir nível 10, você desbloqueia uma habilidade especial: “Cura em Grupo”. Crie uma classe CuraEmGrupo extends Habilidade que cura 50% do HP máximo E reduz dano sofrido em 30% no próximo turno.

Solução de referência. Hierarquia Habilidade abstrata com método podeExecutar() e executar() é o padrão Strategy disfarçado. Cada subclasse define quando pode ser usada (gate por nível, classe, ouro) e o que faz. Habilidade carrega seu próprio estado (cooldown, buff residual).

abstract class Habilidade {
  final String nome;
  Habilidade(this.nome);

  bool podeExecutar(Jogador j);
  void executar(Jogador j);
}

class CuraEmGrupo extends Habilidade {
  int cooldownRestante = 0;

  CuraEmGrupo() : super('Cura em Grupo');

  @override
  bool podeExecutar(Jogador j) {
    return j.nivel >= 10 && cooldownRestante == 0;
  }

  @override
  void executar(Jogador j) {
    var cura = (j.maxHp * 0.5).round();
    j.hp = (j.hp + cura).clamp(0, j.maxHp);
    j.aplicarBuff(BuffReducaoDano(porcentagem: 0.30, turnos: 1));
    cooldownRestante = 10;
    print('Cura em Grupo! +$cura HP. Redução de dano por 1 turno.');
  }

  void tickTurno() {
    if (cooldownRestante > 0) cooldownRestante--;
  }
}

class BuffReducaoDano {
  double porcentagem;
  int turnos;
  BuffReducaoDano({required this.porcentagem, required this.turnos});
}

A separação “podeExecutar / executar” permite UI desenhar habilidades cinzas (cooldown) ou destacadas (prontas). Variação: habilidade ativa e passiva - “Cura em Grupo” também cura 1 HP por turno passivamente. Modelar como Habilidade com método aoFimDoTurno() opcional. Outra: árvore de habilidades onde cada nível desbloqueia 2-3 opções, jogador escolhe uma - decisões definitivas geram identidade ao personagem.

Desafio 25.5 - Distribuição de Pontos de Poder

Cada level up dá 2 “Pontos de Habilidade”. Você investe: (+1 HP por ponto), (+1 Ataque por ponto), (+1 Defesa por 3 pontos = reduz 5% dano).

Solução de referência. Pontos para alocar manualmente é decisão clássica de RPG. O ponto interessante é o balanço de custos - +1 ataque vs +1 HP têm custos iguais (1 ponto), mas defesa é mais “cara” (3 pontos por +5% redução). Isso reflete que defesa multiplica todos os danos futuros, então custa mais.

class Jogador {
  int nivel = 1;
  int pontosHabilidade = 0;
  int hp = 100, maxHp = 100, ataque = 5;
  double reducaoDano = 0.0;

  void subirNivel() {
    nivel++;
    pontosHabilidade += 2;
    print('Subiu para nível $nivel! +2 pontos (total: $pontosHabilidade)');
  }

  bool gastarPontoEm(String stat) {
    switch (stat) {
      case 'hp':
        if (pontosHabilidade < 1) return false;
        pontosHabilidade--;
        maxHp++;
        hp = maxHp;
        return true;
      case 'ataque':
        if (pontosHabilidade < 1) return false;
        pontosHabilidade--;
        ataque++;
        return true;
      case 'defesa':
        if (pontosHabilidade < 3) return false;
        pontosHabilidade -= 3;
        reducaoDano += 0.05;
        return true;
    }
    return false;
  }
}

void menuPontos(Jogador j) {
  while (j.pontosHabilidade > 0) {
    print('Você tem ${j.pontosHabilidade} pontos. Em quê investir?');
    print('  hp (+1 HP, custa 1)');
    print('  ataque (+1 ATK, custa 1)');
    print('  defesa (-5% dano, custa 3)');
    stdout.write('> ');
    var entrada = (stdin.readLineSync() ?? '').toLowerCase().trim();
    if (entrada == 'pular' || entrada.isEmpty) break;
    if (!j.gastarPontoEm(entrada)) print('Inválido ou pontos insuficientes.');
  }
}

Permitir “pular” (guardar pontos para depois) é importante - jogador pode preferir esperar e fazer escolha melhor com mais informação. Variação: pontos permanecem entre níveis (acumulam) vs forçam alocar imediatamente. Para roguelike, acumular dá sensação de controle. Outra: stats “soft cap” - +1 HP é barato até HP 200, depois custa 2 pontos. Cria identidade nas builds: builds que insistem em “tudo HP” pagam progressivamente mais.

Boss Final 25.6 - Invencibilidade Temporária

Se você derrotar 5 inimigos seguidos sem sofrer dano, você entra em “Fúria Perfeita” e ganha +50% XP na próxima vitória.

Solução de referência. Streak mechanics são o que tornam jogo “agressivo” interessante - recompensa quem joga bem (sem dano), pune quem só sobrevive. O contador streakSemDano zera quando o jogador toma dano de qualquer fonte (ataque inimigo, armadilha, fome).

class Jogador {
  int streakSemDano = 0;
  bool furiaPerfeita = false;

  @override
  void sofrerDano(int valor) {
    super.sofrerDano(valor);
    if (valor > 0) {
      if (streakSemDano > 0) {
        print('Streak quebrado em $streakSemDano.');
      }
      streakSemDano = 0;
      furiaPerfeita = false;
    }
  }

  void derrotouInimigo(Inimigo morto) {
    streakSemDano++;
    if (streakSemDano >= 5) {
      furiaPerfeita = true;
      print('FÚRIA PERFEITA! +50% XP na próxima vitória.');
    }
    var xpGanho = morto.maxHp * 2;
    if (furiaPerfeita) {
      xpGanho = (xpGanho * 1.5).round();
      furiaPerfeita = false;  // consome o bônus
      print('  +XP em fúria: $xpGanho');
    }
    ganharXp(xpGanho);
  }
}

O detalhe importante é furiaPerfeita ser consumida ao próxima vitória - se ficasse permanente, viraria multiplicador infinito. Variação: escalada de stacks - streak 5 = +50%, 10 = +100%, 15 = +200% (overflow). Outra: efeitos visuais - jogador ”@” pisca dourado quando em Fúria, sinaliza para o jogador o status. Para roguelike mais punitivo, streak quebrar leva penalidade (-10% XP nas próximas 3 vitórias), criando montanha-russa emocional.


Capítulo 26 - Múltiplos Andares e o Boss Final

Desafio 26.1 - Fúria do Chefão

O Chefão Antigo entra em fúria quando ferido. Mude suas fases: de 66%/33% de HP para 75%/50% (fica furioso por mais tempo, mais ameaçador). Implemente em atualizarFase().

Solução de referência. Bosses por fase são padrão de design: cada fase muda comportamento, mantém o combate fresco. O limiar de mudança define quanta da luta o jogador passa em cada estado. Mover de 66/33 para 75/50 faz a “fase final” começar antes - boss fica mais cedo perigoso.

enum FaseBoss { calmo, irritado, furioso }

class Chefao extends Inimigo {
  FaseBoss fase = FaseBoss.calmo;

  Chefao()
      : super(nome: 'Chefão Antigo', hp: 200, maxHp: 200,
              ataque: 10, simbolo: 'B', descricao: 'Olhos antigos brilham na escuridão.');

  void atualizarFase() {
    var razao = hp / maxHp;
    var anterior = fase;
    if (razao > 0.75) {
      fase = FaseBoss.calmo;
    } else if (razao > 0.50) {
      fase = FaseBoss.irritado;
    } else {
      fase = FaseBoss.furioso;
    }
    if (fase != anterior) {
      print(_anuncioMudanca(fase));
    }
  }

  String _anuncioMudanca(FaseBoss nova) {
    switch (nova) {
      case FaseBoss.calmo: return 'O Chefão observa, paciente.';
      case FaseBoss.irritado: return 'O Chefão range os dentes. Sua fúria cresce.';
      case FaseBoss.furioso: return 'O CHEFÃO EXPLODE EM FÚRIA. Tudo vibra.';
    }
  }

  int danoAtaque() {
    switch (fase) {
      case FaseBoss.calmo: return ataque;
      case FaseBoss.irritado: return (ataque * 1.5).round();
      case FaseBoss.furioso: return ataque * 2;
    }
  }
}

A print no _anuncioMudanca é chamada só na transição (fase mudou) - sem isso, anunciaria a cada turno mesmo sem mudança. Variação: cada fase com habilidade própria - calmo só ataca, irritado invoca minions, furioso golpe AoE. Outra: voltar à fase anterior se for curado para acima do limiar (interessantíssimo se o jogador conseguir aplicar dano negativo).

Desafio 26.2 - Legiões da Sombra

Em vez de derrotar o Chefão sozinho, ele invoca 2 capangas quando entra em fúria. Implemente gerarCapangas() que retorna lista de 2 Zumbis. Adicione ao game state quando o boss muda para fúria.

Solução de referência. Spawn de capangas é a primeira vez que o boss “controla o mundo” - cria entidades que afetam o resto do combate. O detalhe é que os capangas devem somar ao game state, não simplesmente aparecer no log; o sistema de combate em grupo (cap. 14 desafio 14.5) lida com isso.

class Zumbi extends Inimigo {
  Zumbi()
      : super(nome: 'Zumbi', hp: 8, maxHp: 8, ataque: 3, simbolo: 'Z', descricao: '');
}

class Chefao extends Inimigo {
  // ... como antes
  bool capangasInvocados = false;

  List<Inimigo> gerarCapangas() => [Zumbi(), Zumbi()];

  @override
  void atualizarFase() {
    var anterior = fase;
    super.atualizarFase();  // (se Inimigo tivesse comportamento default; aqui simulando)
    if (anterior != FaseBoss.furioso && fase == FaseBoss.furioso && !capangasInvocados) {
      capangasInvocados = true;
    }
  }
}

class GerenciadorCombate {
  Jogador jogador;
  Chefao boss;
  List<Inimigo> capangas = [];

  GerenciadorCombate(this.jogador, this.boss);

  void turno() {
    // 1. boss age
    if (boss.hp > 0) {
      jogador.sofrerDano(boss.danoAtaque());
      boss.atualizarFase();
      if (boss.capangasInvocados && capangas.isEmpty) {
        capangas = boss.gerarCapangas();
        print('Capangas invocados: ${capangas.length}');
      }
    }

    // 2. capangas atacam
    for (var c in capangas) {
      if (c.hp > 0) jogador.sofrerDano(c.ataque);
    }
  }
}

A flag capangasInvocados impede que o boss invoque a cada turno em fúria - só uma vez. Variação: invocação recorrente (ex: a cada 5 turnos em fúria, sobem mais 2 zumbis); ou condicional ao HP do boss (cada 25% perdido invoca novos). Outra: capangas com vínculo ao boss - quando boss morre, eles também caem (“ele os controlava”). Mecânica narrativa que tem peso real no design.

Desafio 26.3 - A Arena Final

A sala do boss é especial: 8x8, paredes irregulares, escada de saída só aparece após derrota. Crie SalaBossArena que sobrescreve temSaida() para retornar true somente se o boss está morto.

Solução de referência. Sala-arena é caso especial estendendo a SalaCombate do cap. 10. O método temSaida() controla se o jogador pode mover - antes do boss morrer, retorna false; depois, libera. Visualmente, a escada aparece quando o boss cai.

class SalaBossArena extends SalaCombate {
  Chefao boss;

  SalaBossArena({
    required String nome,
    required String descricao,
    required this.boss,
    Map<String, String>? saidas,
  }) : super(nome: nome, descricao: descricao, saidas: saidas, inimigo: boss);

  bool temSaida() => boss.hp <= 0;

  @override
  bool podeSair() => temSaida();

  String descreverArena() {
    var sb = StringBuffer();
    sb.writeln('╔════════════════════════╗');
    sb.writeln('║   ARENA DO CHEFÃO      ║');
    sb.writeln('╠════════════════════════╣');
    sb.writeln('║ ${boss.nome.padRight(22)} ║');
    sb.writeln('║ HP: ${boss.hp}/${boss.maxHp}'.padRight(25) + '║');
    sb.writeln('║ Fase: ${boss.fase.name}'.padRight(25) + '║');
    if (boss.hp <= 0) {
      sb.writeln('║ ESCADA APARECEU! >>>   ║');
    }
    sb.writeln('╚════════════════════════╝');
    return sb.toString();
  }
}

A sala desenha o status do boss no próprio descritor - jogador sempre sabe quanto falta. Variação: arena que muda visualmente conforme fase do boss (calmo = ar limpo, irritado = pedras tremem, furioso = fogo no chão dando dano por turno). Outra: ondas - sala tem várias “fases”, cada uma com mini-boss, e o “boss final” só aparece após derrotar 3 sub-bosses.

Desafio 26.4 - O Prêmio da Vitória

Ao derrotar o boss, você ganha 1000 XP + uma “Coroa de Lendas” (item raro). Implemente recompensaBoss() que retorna esse drop fixo, sem aleatoriedade.

Solução de referência. Recompensas de boss são garantidas - se fossem aleatórias, jogador frustrado de RNG ruim atrofiaria. Drop fixo: 100% de certas coisas, talvez aleatórias entre 2-3 opções “bem narrativas”.

class RecompensaBoss {
  final int xp;
  final ItemVenda item;
  RecompensaBoss({required this.xp, required this.item});
}

RecompensaBoss recompensaBoss(Chefao boss) {
  return RecompensaBoss(
    xp: 1000,
    item: ItemVenda(
      nome: 'Coroa de Lendas',
      id: 'coroa_lendas',
      preco: 0,  // não tem preço, é troféu
      descricao: 'Você derrotou o Chefão Antigo. Esta coroa marca sua glória.',
      raro: true,
    ),
  );
}

void aoVencerBoss(Jogador j, Chefao boss, BarramentoEventos b) {
  var recompensa = recompensaBoss(boss);
  j.ganharXp(recompensa.xp);
  j.inventario.add(recompensa.item);
  b.publicar(EventoCombate(alvo: boss.nome, dano: -recompensa.xp));
  print('╔════════════════════════╗');
  print('║   BOSS DERROTADO       ║');
  print('╠════════════════════════╣');
  print('║ +${recompensa.xp} XP'.padRight(25) + '║');
  print('║ Recebeu: ${recompensa.item.nome}'.padRight(25) + '║');
  print('╚════════════════════════╝');
}

A “Coroa de Lendas” não vai para o estoque de loja - só fica no inventário do jogador como troféu (e talvez bônus passivo). Variação: itens aleatórios mas garantidos (Lista de 5 possíveis, 1 é sorteado) - mantém replay value. Outra: recompensa escalada por dificuldade da run - boss derrotado no modo Veterano dá XP dobrado.

Desafio 26.5 - Jogo se Adapta a Você

Implemente IA adaptativa: se o jogador venceu muito fácil (HP > 80% no fim), próximo boss é 20% mais forte. Se quase morreu, 20% mais fraco. Crie dificuldadeAdaptativa(Jogador j) que retorna multiplicador.

Solução de referência. Dificuldade dinâmica (DDA - Dynamic Difficulty Adjustment) ajusta o desafio sem o jogador escolher modo. A função examina o estado pós-combate e prediz “esse jogador está fácil/difícil” - próximo boss reflete isso.

class HistoricoCombate {
  final List<double> hpFinalRelativo = [];  // 0.0 - 1.0

  void registrar(int hp, int maxHp) {
    hpFinalRelativo.add(hp / maxHp);
  }

  double dificuldadeRecomendada() {
    if (hpFinalRelativo.isEmpty) return 1.0;
    // média dos últimos 3 combates
    var ultimos = hpFinalRelativo.length > 3
        ? hpFinalRelativo.sublist(hpFinalRelativo.length - 3)
        : hpFinalRelativo;
    var media = ultimos.reduce((a, b) => a + b) / ultimos.length;

    if (media > 0.80) return 1.20;  // muito fácil: aumenta 20%
    if (media < 0.30) return 0.80;  // muito difícil: reduz 20%
    return 1.0;  // ok
  }
}

void aplicarDificuldadeAdaptativa(Chefao boss, HistoricoCombate h) {
  var mult = h.dificuldadeRecomendada();
  boss.hp = (boss.hp * mult).round();
  boss.maxHp = boss.hp;
  boss.ataque = (boss.ataque * mult).round();
  print('Dificuldade adaptada: x${mult.toStringAsFixed(2)}');
}

O cuidado é não tornar a adaptação visível ao jogador - se cada vez que ele vence o próximo boss é mais forte, ele percebe e se irrita. Solução: aplicar gradualmente, e nunca além de ±30% do base. Variação: adaptar drops em vez de HP/ATK - jogador que sofre mais ganha mais ouro, evitando “muro” econômico. Mais sutil, igualmente eficaz.

Boss Final 26.6 - Troféu de Glória

Ao vencer o Chefão Antigo, salve um JSON com a data, XP final, nivel atingido, andar máximo e tempo de partida. Crie arquivo trofeu_glora.json em ~/.masmorra/. Em runs futuras, leia esse arquivo e mostre “Vitórias anteriores: 3” na HUD.

Solução de referência. Persistência entre runs é o que transforma roguelike em “metaprogressão”. O arquivo trofeu_glora.json (typo do enunciado preservado intencionalmente) acumula histórico - cada vitória vira mais uma linha no array.

import 'dart:convert';
import 'dart:io';

class TrofeuRun {
  final DateTime data;
  final int xpFinal;
  final int nivel;
  final int andarMaximo;
  final Duration duracao;

  TrofeuRun({
    required this.data, required this.xpFinal, required this.nivel,
    required this.andarMaximo, required this.duracao,
  });

  Map<String, dynamic> toJson() => {
    'data': data.toIso8601String(),
    'xp_final': xpFinal,
    'nivel': nivel,
    'andar_maximo': andarMaximo,
    'duracao_segundos': duracao.inSeconds,
  };

  factory TrofeuRun.fromJson(Map<String, dynamic> j) => TrofeuRun(
    data: DateTime.parse(j['data'] as String),
    xpFinal: j['xp_final'] as int,
    nivel: j['nivel'] as int,
    andarMaximo: j['andar_maximo'] as int,
    duracao: Duration(seconds: j['duracao_segundos'] as int),
  );
}

File _arquivoTrofeu() {
  var home = Platform.environment['HOME'] ?? '.';
  var dir = Directory('$home/.masmorra');
  if (!dir.existsSync()) dir.createSync(recursive: true);
  return File('${dir.path}/trofeu_glora.json');
}

List<TrofeuRun> carregarTrofeus() {
  var f = _arquivoTrofeu();
  if (!f.existsSync()) return [];
  var lista = jsonDecode(f.readAsStringSync()) as List;
  return lista.map((j) => TrofeuRun.fromJson(j as Map<String, dynamic>)).toList();
}

void salvarTrofeu(TrofeuRun novo) {
  var trofeus = carregarTrofeus();
  trofeus.add(novo);
  _arquivoTrofeu().writeAsStringSync(
    jsonEncode(trofeus.map((t) => t.toJson()).toList()),
  );
}

void main() {
  // ao vencer:
  salvarTrofeu(TrofeuRun(
    data: DateTime.now(),
    xpFinal: 8500,
    nivel: 14,
    andarMaximo: 20,
    duracao: Duration(minutes: 47),
  ));

  // ao iniciar nova run:
  var historico = carregarTrofeus();
  print('Vitórias anteriores: ${historico.length}');
  if (historico.isNotEmpty) {
    var melhor = historico.reduce((a, b) => a.xpFinal > b.xpFinal ? a : b);
    print('Melhor run: nível ${melhor.nivel}, ${melhor.xpFinal} XP');
  }
}

Platform.environment['HOME'] funciona em Linux/macOS; no Windows, use USERPROFILE. Para portabilidade, package path_provider (Flutter) ou package:path resolvem. Variação: estatísticas agregadas - “total de inimigos derrotados”, “média de tempo por vitória” - revelar conquistas escondidas. Outra: leaderboard online (cap. 33) que envia trofeus para servidor após vitória.


Capítulo 27 - Dungeon Run Completo: A Jornada Épica

Desafio 27.1 - Seu Reino, Seu Brasão

Customize a tela de início com um brasão ASCII art autoral. Use box-drawing e símbolos para criar uma marca visual do seu jogo.

Solução de referência. A tela de início é a primeira impressão. ASCII art autoral marca personalidade - o jogador sabe que está numa criação intencional. Escolha de símbolos ╬ ◆ ★ em vez de # * + muda completamente o tom.

String brasaoInicio() {
  return r'''
╔═══════════════════════════╗
║                           ║
║      ⚔  MASMORRA  ⚔        ║
║                           ║
║         ASCII             ║
║      ┌─◆───◆─┐            ║
║      │  ╳ ╳  │            ║
║      │   ╳   │            ║
║      └───┬───┘            ║
║         ╳                 ║
║                           ║
║   "A escuridão aguarda"   ║
║                           ║
╚═══════════════════════════╝
''';
}

void telaInicio() {
  print(brasaoInicio());
  print('  [ Enter para começar ]');
  stdin.readLineSync();
}

A indentação fixa (8 espaços) centra visualmente em terminal 80 colunas. Variação: animação de “carregamento” (cada caractere aparece em sequência, sleep(20ms) por char) - efeito teatral. Outra: brasão muda conforme conquistas (após primeira vitória, ganha estrela; após 10 vitórias, ganha coroa).

Desafio 27.2 - A Descida Sem Fim

Adicione comando proximo_andar que gera andar mais difícil e teleporta o jogador. Use a regra do cap. 15 desafio 15.4 para múltiplos andares.

Solução de referência. A descida é o loop central do roguelike. Cada andar = nova geração + posicionamento na escada de subida. Persistir andares anteriores (cache) versus regenerar (sempre novo) é decisão de design - cache permite voltar e estratégias multi-andar; regenerar dá imprevisibilidade contínua.

class JogoCompleto {
  final List<MapaMasmorra> andares = [];
  int andarAtual = 0;
  Jogador jogador;
  Random rng;

  JogoCompleto(this.jogador, {int? semente})
      : rng = Random(semente ?? DateTime.now().millisecondsSinceEpoch) {
    descerNovoAndar();
  }

  void descerNovoAndar() {
    andarAtual++;
    print('--- Andar $andarAtual ---');
    var mapa = gerarRoomsCorridors(40, 20, rng: rng, salas: 6 + andarAtual);
    DistribuidorEntidades(rng).distribuir(
      mapa: mapa, entrada: mapa.entradaPosicao(),
      salas: mapa.salas(), andar: andarAtual,
    );
    andares.add(mapa);

    var entrada = mapa.entradaPosicao();
    jogador.x = entrada.x;
    jogador.y = entrada.y;
  }

  bool comandoProximoAndar() {
    var sala = andares.last;
    var t = sala.grade[jogador.y][jogador.x];
    if (t != Tile.escadaDesce) {
      print('Não há escada aqui.');
      return false;
    }
    descerNovoAndar();
    return true;
  }
}

descerNovoAndar reusa cap. 18 (geração), cap. 20 (distribuição) - infraestrutura já está pronta. O número de salas crescente (6 + andarAtual) faz andares mais profundos serem maiores - sensação de “mundo se abrindo”. Variação: andares com temas - andar 5 caverna gelada, andar 10 catacumba, andar 20 templo - mude algoritmo gerador ou paleta visual conforme andar.

Desafio 27.3 - Seus Recordes

Salve estatísticas pessoais: maior andar atingido, mais ouro acumulado, inimigos derrotados total. Mostre na HUD: “Recorde: andar 18”.

Solução de referência. Recordes pessoais alimentam o “só mais uma run”. O arquivo JSON do cap. 26 já tem infraestrutura - basta agregar.

class RecordesPessoais {
  int maiorAndar = 0;
  int maiorOuro = 0;
  int inimigosTotal = 0;
  int runsCompletas = 0;

  void atualizarComRun(int andarFinal, int ouroFinal, int matados) {
    if (andarFinal > maiorAndar) maiorAndar = andarFinal;
    if (ouroFinal > maiorOuro) maiorOuro = ouroFinal;
    inimigosTotal += matados;
    runsCompletas++;
  }

  Map<String, dynamic> toJson() => {
    'maior_andar': maiorAndar,
    'maior_ouro': maiorOuro,
    'inimigos_total': inimigosTotal,
    'runs_completas': runsCompletas,
  };

  factory RecordesPessoais.fromJson(Map<String, dynamic> j) =>
      RecordesPessoais()
        ..maiorAndar = j['maior_andar'] as int? ?? 0
        ..maiorOuro = j['maior_ouro'] as int? ?? 0
        ..inimigosTotal = j['inimigos_total'] as int? ?? 0
        ..runsCompletas = j['runs_completas'] as int? ?? 0;
}

String linhaHudRecordes(RecordesPessoais r) {
  return 'Recorde: andar ${r.maiorAndar} | ${r.maiorOuro} ouro | ${r.inimigosTotal} mortos';
}

Variação: recordes por modo de dificuldade (recruta, normal, veterano) - cada um com seu top. Outra: comparar com a run atual em tempo real (“Andar atual: 7 / Recorde: 12 - faltam 5”). Motivação direta.

Desafio 27.4 - Seu Jogo, Suas Regras

Crie um arquivo config.json com parâmetros customizáveis: hpInicial, seed, velocidadeMovimento. Carregue ao iniciar.

Solução de referência. Config externo separa conteúdo de regras. Jogador modder customiza sem recompilar; desenvolvedor balanceia sem mexer no código.

import 'dart:io';
import 'dart:convert';

class ConfiguracaoJogo {
  int hpInicial;
  int? seed;
  int velocidadeMovimentoMs;
  Dificuldade dificuldade;

  ConfiguracaoJogo({
    this.hpInicial = 100,
    this.seed,
    this.velocidadeMovimentoMs = 80,
    this.dificuldade = Dificuldade.normal,
  });

  factory ConfiguracaoJogo.carregar([String caminho = 'config.json']) {
    var f = File(caminho);
    if (!f.existsSync()) {
      return ConfiguracaoJogo();
    }
    var j = jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;
    return ConfiguracaoJogo(
      hpInicial: j['hp_inicial'] as int? ?? 100,
      seed: j['seed'] as int?,
      velocidadeMovimentoMs: j['velocidade_movimento_ms'] as int? ?? 80,
      dificuldade: Dificuldade.values.firstWhere(
        (d) => d.name == (j['dificuldade'] as String?),
        orElse: () => Dificuldade.normal,
      ),
    );
  }

  void salvar([String caminho = 'config.json']) {
    File(caminho).writeAsStringSync(jsonEncode({
      'hp_inicial': hpInicial,
      'seed': seed,
      'velocidade_movimento_ms': velocidadeMovimentoMs,
      'dificuldade': dificuldade.name,
    }));
  }
}

Defaults para campos faltantes (?? 100) tornam o JSON tolerante a versão antiga - você adicionou velocidadeMovimentoMs em v2, mas v1 do save não tem; default cobre. Variação: validar ranges (hpInicial entre 50 e 999, senão erro); ou aceitar formato YAML em vez de JSON - mais legível para humanos.

Desafio 27.5 - Corrida Contra o Tempo

Implemente modo “corrida”: jogador tem 5 minutos reais para chegar ao máximo andar possível. Use Stopwatch para medir.

Solução de referência. Modo corrida traz adrenalina ao roguelike. O Stopwatch do Dart marca tempo real (não turnos), independente da pausa. Renderizar contagem regressiva na HUD a cada turno (não precisa de frame loop).

class ModoCorrida {
  final Stopwatch cronometro = Stopwatch();
  final Duration limite;
  int andarMaximoAtingido = 0;

  ModoCorrida({this.limite = const Duration(minutes: 5)}) {
    cronometro.start();
  }

  Duration get tempoRestante {
    var r = limite - cronometro.elapsed;
    return r.isNegative ? Duration.zero : r;
  }

  bool get acabou => tempoRestante == Duration.zero;

  String hudTempo() {
    var t = tempoRestante;
    var min = t.inMinutes;
    var seg = t.inSeconds % 60;
    var cor = t.inSeconds < 30 ? '\u001B[31m' : '\u001B[32m';
    return '${cor}TEMPO: ${min.toString().padLeft(2, "0")}:${seg.toString().padLeft(2, "0")}\u001B[0m';
  }

  void registrarAndar(int andar) {
    if (andar > andarMaximoAtingido) andarMaximoAtingido = andar;
  }
}

A cor vermelha nos últimos 30 segundos sinaliza urgência. Variação: pausar cronômetro durante menus (pause não conta como tempo) - chame cronometro.stop() em pause, start() ao voltar. Outra: bônus de tempo por matar boss (+30s), penalidade por morrer (-15s) - escolhas afetam tempo, não só HP.

Boss Final 27.6 - Economia Equilibrada

Faça um sistema completo: jogador começa com 100 ouro, precisa gerenciar para comprar poções/equipamentos. Inimigos drop ouro e itens; bosses drop muito. Loja entre andares vende itens úteis. Teste 5 runs completas (até andar 10), anote: jogadores acabam ricos ou pobres? Equilibrado é “termina com 100-500 ouro líquido”.

Solução de referência. Balanceamento econômico é arte: 5 runs completas é mínimo para sentir, 50 runs daria dados estatísticos. O objetivo “termina com 100-500” significa que o jogador não tem trivialmente todos os itens, mas também não fica sufocado. Trabalha sob restrição agradável.

class SimulacaoRunCompleta {
  Jogador jogador;
  Random rng;

  SimulacaoRunCompleta(int semente)
      : rng = Random(semente),
        jogador = Jogador(Dificuldade.normal)..ouro = 100;

  Map<String, dynamic> rodar() {
    var ouroGanho = 0;
    var ouroGasto = 0;

    for (var andar = 1; andar <= 10; andar++) {
      var inimigos = 5 + andar;
      for (var i = 0; i < inimigos; i++) {
        var dropOuro = 20 + rng.nextInt(40);
        jogador.ouro += dropOuro;
        ouroGanho += dropOuro;
      }
      if (rng.nextDouble() < 0.5 && jogador.ouro >= 50) {
        jogador.ouro -= 50;
        ouroGasto += 50;
      }
    }

    return {
      'ouro_final': jogador.ouro,
      'ouro_ganho': ouroGanho,
      'ouro_gasto': ouroGasto,
    };
  }
}

void main() {
  print('Run | Ganho | Gasto | Final');
  for (var s = 1; s <= 5; s++) {
    var sim = SimulacaoRunCompleta(s);
    var r = sim.rodar();
    print('  $s  | ${r["ouro_ganho"]}   | ${r["ouro_gasto"]}   | ${r["ouro_final"]}');
  }
}

Os números (drops 20-60, poção 50, 50% chance de comprar) são chutes iniciais. Rodar a simulação revela: se média final > 500, drops estão altos demais ou loja barata demais. Ajuste, simule de novo. Isso é design iterativo, e simulação economiza tempo de jogar mil partidas. Variação: rodar 100 runs com sementes diferentes, gerar histograma do ouro final - se distribuição for centrada em 300 com variância pequena, o balanço está ótimo; se for em 50 com cauda longa, é luck-dependent.


Capítulo 28 - Refatoração Guiada: Code Smells e Limpeza Estrutural

Desafio 28.1 - Audit de Saúde

Conte: quantas linhas tem o arquivo principal? Quantas classes? Quantos métodos com mais de 30 linhas? Imprima um relatório. Acima de 500 linhas, classe começa a sofrer.

Solução de referência. Auditoria por linhas é heurística simples - não substitui code review, mas dá sinal. Em Dart, um script pequeno percorre os arquivos e conta tokens estruturais (class, void, ;). O ponto pedagógico: nem todo arquivo gordo é ruim, mas todo arquivo gordo merece olhar.

import 'dart:io';

class Auditoria {
  int totalLinhas = 0;
  int totalClasses = 0;
  int totalMetodos = 0;
  List<String> metodosLongos = [];

  void analisar(String caminho) {
    var f = File(caminho);
    var linhas = f.readAsLinesSync();
    totalLinhas = linhas.length;

    var dentroDeMetodo = false;
    var inicioMetodo = -1;
    var nomeMetodo = '';

    for (var i = 0; i < linhas.length; i++) {
      var linha = linhas[i].trim();
      if (linha.startsWith('class ')) totalClasses++;
      var matchMetodo = RegExp(r'(?:void|int|String|bool|double|Future)\s+(\w+)\s*\(').firstMatch(linha);
      if (matchMetodo != null && !dentroDeMetodo) {
        nomeMetodo = matchMetodo.group(1)!;
        inicioMetodo = i;
        dentroDeMetodo = true;
        totalMetodos++;
      }
      if (dentroDeMetodo && linha == '}') {
        var tamanho = i - inicioMetodo;
        if (tamanho > 30) metodosLongos.add('$nomeMetodo (${tamanho} linhas)');
        dentroDeMetodo = false;
      }
    }
  }

  void imprimirRelatorio() {
    print('=== AUDITORIA ===');
    print('Linhas: $totalLinhas ${totalLinhas > 500 ? "(ATENCAO: arquivo gordo)" : ""}');
    print('Classes: $totalClasses');
    print('Metodos: $totalMetodos');
    print('Metodos > 30 linhas: ${metodosLongos.length}');
    for (var m in metodosLongos) {
      print('  - $m');
    }
  }
}

void main() {
  var a = Auditoria();
  a.analisar('bin/masmorra.dart');
  a.imprimirRelatorio();
}

O regex é grosseiro - confunde declaração de variável int total = com método. Para análise séria, use package:analyzer (AST parser oficial). Para auditoria rápida, o regex serve. Variação: limite por método (acima de 30 vira yellow, acima de 60 red); contar parâmetros (método com > 5 parâmetros é suspeito); detectar duplicação (linhas idênticas em vários lugares).

Desafio 28.2 - Cirurgião de Código

Encontre uma função com mais de 50 linhas no jogo. Extraia 3 funções menores. Compare: a função original era confusa? As novas são mais claras?

Solução de referência. Extract Method é o refactor mais fundamental. Identificar passagens lógicas distintas numa função longa é o exercício mental. Cada passagem extraída ganha nome - o nome documenta o que fazia naquele bloco sem comentário.

Antes (60 linhas):

void processarTurno(Jogador j, MapaMasmorra mapa) {
  // 1. mover jogador (15 linhas)
  // 2. detectar colisoes com entidades (12 linhas)
  // 3. atualizar HUD (8 linhas)
  // 4. mover inimigos (15 linhas)
  // 5. aplicar efeitos atmosfericos (10 linhas)
}

Depois (extraído):

void processarTurno(Jogador j, MapaMasmorra mapa) {
  _moverJogador(j, mapa);
  _resolverColisoes(j, mapa);
  _atualizarHud(j, mapa);
  _moverInimigos(mapa, j);
  _aplicarEfeitosAtmosfericos(j, mapa);
}

void _moverJogador(Jogador j, MapaMasmorra mapa) {
  // 15 linhas focadas so em movimento
}

void _resolverColisoes(Jogador j, MapaMasmorra mapa) {
  // 12 linhas focadas em colisoes
}
// ... etc.

A função “antes” exigia ler 60 linhas para entender o turno; a “depois” lê 5 linhas e cada parte vira detalhe se você quiser. Variação: extrair como métodos da classe vs como funções top-level - depende se compartilham estado. Quando uma função extraída usa só campos de uma classe, vire método. Quando é puro (entrada-saída), top-level.

Desafio 28.3 - Constantes Nomeadas

Procure por números mágicos: if (hp < 30), xp += 5. Substitua por constantes nomeadas: if (hp < kHpCritico), xp += kXpPorInimigo. Reorganize tudo em uma classe Constantes.

Solução de referência. “Número mágico” é qualquer literal cujo significado depende de você lembrar - péssimo em jogos onde valores são tunados constantemente. Constantes nomeadas trazem dois ganhos: documentação (“30 é o limiar crítico de HP”) e reuso (se aparece em 5 lugares, mudar é 1 linha).

class Constantes {
  // HP
  static const int hpInicial = 100;
  static const int hpCritico = 30;
  static const int hpRegenPorTurno = 1;

  // XP / Progressao
  static const int xpPorInimigoBase = 5;
  static const double multiplicadorXpBoss = 5.0;
  static const int nivelHabilidadeEspecial = 10;

  // Combate
  static const double chanceCritico = 0.15;
  static const int multiplicadorCritico = 2;
  static const int limiteTurnoCombate = 10;

  // Economia
  static const double markupLoja = 1.2;
  static const double margemVenda = 0.5;
  static const int ouroInicialJogador = 100;
}

void verificarHp(Jogador j) {
  if (j.hp < Constantes.hpCritico) {
    print('HP critico!');
  }
}

void ganharXp(Jogador j, Inimigo morto) {
  var xp = Constantes.xpPorInimigoBase * morto.maxHp;
  j.xp += xp;
}

static const é compile-time - sem custo de runtime, pode ser usado em case de switch. Variação: organizar por escopo (constantes de combate em ConstantesCombate, de UI em ConstantesUI) - quando o arquivo passar de 50 constantes, agrupar evita lista monolítica. Outra: constantes lidas de arquivo de configuração (cap. 27.4) - permite balanceamento sem recompilar.

Desafio 28.4 - Organização Profissional

Reestruture o projeto em pastas: lib/jogo/, lib/mundo/, lib/personagens/, lib/util/. Cada categoria com seu propósito.

Solução de referência. Estrutura de pastas é convenção - ninguém te força, todo mundo aprecia. A regra é “by feature, not by type”: agrupe arquivos pelo o que fazem (jogo, combate, UI), não pelo o que são (controllers, models, views). Para projetos pequenos, lib/ flat é suficiente; conforme cresce, subpastas viram necessidade.

Estrutura sugerida:

masmorra-ascii-dart/
├── pubspec.yaml
├── bin/
│   └── main.dart            # entry point
├── lib/
│   ├── jogo/
│   │   ├── jogo.dart        # Jogo, JogoCompleto
│   │   ├── game_loop.dart   # turno, eventos
│   │   └── combate.dart     # Combate, CombateGrupo
│   ├── mundo/
│   │   ├── mapa.dart        # MapaMasmorra, Tile
│   │   ├── geracao.dart     # gerarRoomsCorridors etc.
│   │   ├── fov.dart         # visibilidade
│   │   └── entidades.dart   # Entidade, EntidadeItem
│   ├── personagens/
│   │   ├── jogador.dart     # Jogador, Combatente
│   │   ├── inimigos.dart    # Orc, Goblin, Dragao
│   │   └── chefao.dart      # Boss
│   ├── ui/
│   │   ├── tela_ascii.dart
│   │   ├── hud.dart
│   │   └── cores.dart
│   ├── persistencia/
│   │   ├── save.dart
│   │   └── trofeu.dart
│   └── util/
│       ├── constantes.dart
│       └── rng.dart
└── test/
    └── ...

bin/main.dart é só void main() => Jogo.iniciar(); - o resto vive em lib/. Pastas em lib/ viram parte do path em imports (import 'package:masmorra/jogo/combate.dart'). Variação: usar src/ dentro de lib/ para código “interno” - convenção do Dart para “não exporte”. Funciona bem em libraries publicadas; para jogo monolítico, é overhead.

Boss Final 28.5 - Quebra da Deus Classe

Encontre uma classe muito grande (mais de 200 linhas, mais de 15 métodos) - provavelmente Jogo ou Jogador. Quebre em 2-3 classes menores com responsabilidades distintas.

Solução de referência. “God object” é antipadrão: uma classe que sabe tudo, faz tudo, e qualquer mudança a toca. A cura é Single Responsibility Principle - cada classe tem uma razão para mudar.

Antes (Jogo com 250 linhas):

class Jogo {
  // estado: jogador, mapa, andares, inventario
  // metodos: iniciar(), turno(), salvar(), carregar(), renderizar(),
  //          processarInput(), atacar(), comprar(), vender(), descer(), subir()
}

Depois (separado por concern):

// 1. Estado puro (dados do jogo)
class EstadoJogo {
  Jogador jogador;
  List<MapaMasmorra> andares;
  int andarAtual;
  EstadoJogo(this.jogador, this.andares, this.andarAtual);
}

// 2. Loop principal (orquestracao)
class GameLoop {
  final EstadoJogo estado;
  final EntradaUsuario entrada;
  final Renderizador renderer;
  final SistemaCombate combate;
  final SistemaLoja loja;

  GameLoop(this.estado, this.entrada, this.renderer, this.combate, this.loja);

  void executar() {
    while (estado.jogador.hp > 0) {
      renderer.desenhar(estado);
      var cmd = entrada.lerComando();
      _despachar(cmd);
    }
  }

  void _despachar(Comando cmd) {
    switch (cmd) {
      case ComandoMover(:final direcao): _mover(direcao);
      case ComandoAtacar(): combate.executar(estado);
      case ComandoLoja(): loja.abrir(estado);
      case _: break;
    }
  }

  void _mover(Direcao d) { /* ... */ }
}

// 3. Sistemas (uma responsabilidade cada)
class SistemaCombate { void executar(EstadoJogo e) { /* combate */ } }
class SistemaLoja { void abrir(EstadoJogo e) { /* compra/venda */ } }
class GerenciadorSave {
  void salvar(EstadoJogo e) { /* json */ }
  EstadoJogo carregar() { /* json */ throw UnimplementedError(); }
}

Cada classe agora tem propósito claro. Trocar a estratégia de combate? Mexe só em SistemaCombate. Adicionar novo formato de save? Só GerenciadorSave. O EstadoJogo é DTO puro - sem comportamento, só dados. Quando o cap. 30 introduzir async, esse Estado vira o snapshot serializável; quando o cap. 34 trouxer Strategy/Command, os sistemas ganham injeção de variantes. A separação é fundação de tudo.


Capítulo 29 - Testes Unitários com package

Desafio 29.1 - Seu Primeiro Escudo

Testes são rede de segurança. Escreva o primeiro teste: uma classe simples como Item ou Arma. Teste cria instância, verifica atributos com expect(item.nome, equals('Espada')). Execute dart test e veja verde.

Solução de referência. Primeiro teste é mais conceitual que técnico: você está criando o hábito de validar antes de confiar. package:test é o padrão Dart; dart test no terminal roda tudo em test/. group agrupa testes relacionados; setUp prepara estado fresco antes de cada test. expect(actual, matcher) é a forma idiomática de afirmar.

// test/item_test.dart
import 'package:test/test.dart';
import 'package:masmorra/util/item.dart';

void main() {
  group('Item', () {
    late Item espada;

    setUp(() {
      espada = Item('Espada', descricao: 'Lamina simples', peso: 500);
    });

    test('cria com atributos corretos', () {
      expect(espada.nome, equals('Espada'));
      expect(espada.peso, equals(500));
    });

    test('toString inclui peso', () {
      expect(espada.toString(), contains('500g'));
    });

    test('itens com mesmo nome sao distintos por identidade', () {
      var outra = Item('Espada', descricao: '', peso: 500);
      expect(espada, isNot(same(outra)));
    });
  });
}

equals é o matcher mais comum; contains, isNot, same, throwsA cobrem outros casos. O setUp evita repetir construção em cada teste - se amanhã o construtor mudar, ajusta uma linha. Variação: tearDown para limpar recursos (fechar arquivos, desconectar mocks); expectAsync para callbacks que devem ser chamados N vezes (útil em testes assíncronos).

Desafio 29.2 - Defendendo Mochila

Escolha Inventario (classe que muda estado). Escreva 5 testes: adicionar/remover, cheia, buscar, usar. Use setUp() que cria mochila fresca para cada teste.

Solução de referência. Classes com estado mutável são o terreno fértil dos testes: o setUp garante isolamento. Cinco testes cobrem o “happy path” - antes de testar erros (próximo desafio), trave os caminhos felizes.

// test/inventario_test.dart
import 'package:test/test.dart';
import 'package:masmorra/personagens/inventario.dart';
import 'package:masmorra/util/item.dart';

void main() {
  group('Inventario', () {
    late Inventario inv;
    late Item espada;
    late Item pocao;

    setUp(() {
      inv = Inventario(capacidade: 3);
      espada = Item('Espada', descricao: '', peso: 500);
      pocao = Item('Pocao', descricao: '', peso: 100);
    });

    test('adicionar item aumenta tamanho', () {
      inv.adicionar(espada);
      expect(inv.tamanho, equals(1));
    });

    test('remover item diminui tamanho', () {
      inv.adicionar(espada);
      inv.remover(espada);
      expect(inv.tamanho, equals(0));
    });

    test('mochila cheia recusa novo item', () {
      for (var i = 0; i < 3; i++) {
        inv.adicionar(Item('Item$i', descricao: '', peso: 10));
      }
      expect(inv.adicionar(espada), isFalse);
      expect(inv.tamanho, equals(3));
    });

    test('buscar por nome encontra item', () {
      inv.adicionar(pocao);
      var encontrado = inv.buscar('Pocao');
      expect(encontrado, equals(pocao));
    });

    test('usar item remove do inventario', () {
      inv.adicionar(pocao);
      inv.usar(pocao);
      expect(inv.contem(pocao), isFalse);
    });
  });
}

Cada teste cabe em 4-7 linhas. Variação: parametrizar com for (var cap in [1, 5, 10]) - testar a mesma lógica em vários tamanhos sem copiar-colar. Outra: usar Matcher customizados para asserções específicas do domínio (expect(inv, contemItemChamado('Espada'))). package:matcher permite criar.

Desafio 29.3 - Erros Esperados

Teste 3 exceções: índice negativo, dividir HP por zero, arquivo inexistente. Use expect(() => inventario[-1], throwsA(isA<RangeError>())).

Solução de referência. throwsA(isA<Tipo>()) espera que o callback lance uma exceção do tipo. Usar throwsRangeError, throwsArgumentError (shortcuts) deixa ainda mais limpo. Para validar a mensagem da exceção, use throwsA(predicate((e) => ...)).

test('acessar indice negativo lanca RangeError', () {
  var inv = Inventario(capacidade: 3);
  expect(() => inv[-1], throwsRangeError);
});

test('dividir HP por zero lanca exception', () {
  expect(() => calcularDanoRelativo(hp: 100, divisor: 0),
         throwsA(isA<IntegerDivisionByZeroException>()));
});

test('carregar arquivo inexistente lanca FileSystemException', () async {
  expect(
    () async => await carregarSave('arquivo_inexistente.json'),
    throwsA(isA<FileSystemException>()),
  );
});

test('validar mensagem da excecao', () {
  expect(
    () => Jogador('').validar(),
    throwsA(predicate((e) => e is ArgumentError && e.message.contains('vazio'))),
  );
});

Para testes async, lembre-se de usar async/await na função do test e marcar o expect com await se for promise. Variação: expectLater para asserções assíncronas; expectAsync1((arg) => ...) para callbacks com argumento.

Desafio 29.4 - RNG Determinístico

Crie RandomFalso extends Random que retorna valores fixos: próximo valor sempre 42, próximo sempre 0.5. Use em testes: com RandomFalso, tabelas de drops são previsíveis.

Solução de referência. Testar código aleatório sem controle de seed é receita para flakiness (“rodou verde 9 vezes, falhou 1 vez sem motivo”). Injetar um Random controlável - via construtor - permite testes determinísticos. Mais limpo que Random(seed) porque o teste define o resultado da próxima chamada, não fica refém da fórmula interna.

class RandomFalso implements Random {
  final List<int> valoresInt;
  final List<double> valoresDouble;
  final List<bool> valoresBool;
  int _i = 0, _d = 0, _b = 0;

  RandomFalso({
    this.valoresInt = const [],
    this.valoresDouble = const [],
    this.valoresBool = const [],
  });

  @override
  int nextInt(int max) {
    if (_i >= valoresInt.length) return 0;
    return valoresInt[_i++] % max;
  }

  @override
  double nextDouble() {
    if (_d >= valoresDouble.length) return 0.0;
    return valoresDouble[_d++];
  }

  @override
  bool nextBool() {
    if (_b >= valoresBool.length) return false;
    return valoresBool[_b++];
  }
}

class Rolador {
  final Random _rng;
  Rolador(this._rng);

  int rolar(String expressao) {
    if (expressao == 'd6') return 1 + _rng.nextInt(6);
    throw FormatException(expressao);
  }
}

void main() {
  test('rolador com fake retorna valor previsivel', () {
    var fake = RandomFalso(valoresInt: [3]);  // proximo nextInt retornara 3
    var r = Rolador(fake);
    expect(r.rolar('d6'), equals(4));  // 1 + 3
  });
}

A regra “construir aceita Random como parâmetro” é DI (Dependency Injection) na forma mais simples. Em código produção, default é Random(); em testes, passa RandomFalso. Variação: Mockito para mocks mais elaborados (when(mock.nextInt(any)).thenReturn(42)); package menos cerimonioso é mocktail.

Boss Final 29.5 - Suite de Defesa

Escolha classe complexa: Inimigo ou Combate. Escreva 9 testes: casos normais (5), extremos (2), exceção (1), com fake (1). Organize em group(), use setUp() compartilhado, execute dart test.

Solução de referência. Suite robusta cobre as três categorias: happy path (5 testes), edge cases (2 testes), exceções (1 teste) + DI (1 teste). 9 testes não é exato - é o mínimo razoável para classe não-trivial. Para Combate, modelo:

void main() {
  group('Combate', () {
    late Jogador jogador;
    late Inimigo orc;
    late Combate combate;
    late RandomFalso fake;

    setUp(() {
      fake = RandomFalso(valoresDouble: [0.9, 0.9, 0.9]);
      jogador = Jogador('Aldric', hp: 100, ataque: 10);
      orc = Orc()..hp = 12;
      combate = Combate(jogador, orc, rng: fake);
    });

    // === 1-5: happy path ===
    test('combate prossegue se ambos vivos', () {
      expect(combate.prossegue(), isTrue);
    });
    test('ataque do jogador reduz HP do inimigo', () {
      combate.executarAtaqueJogador();
      expect(orc.hp, lessThan(12));
    });
    test('vitoria quando inimigo morre', () {
      orc.hp = 1;
      combate.executarAtaqueJogador();
      expect(orc.hp, lessThanOrEqualTo(0));
    });
    test('jogador sofre dano do inimigo', () {
      var hpAntes = jogador.hp;
      combate.executarAtaqueInimigo();
      expect(jogador.hp, lessThan(hpAntes));
    });
    test('turnos sao contados', () {
      combate.executar();
      expect(combate.turno, greaterThan(0));
    });

    // === 6-7: extremos ===
    test('HP zero significa morte', () {
      jogador.hp = 0;
      expect(combate.prossegue(), isFalse);
    });
    test('dano negativo nao cura', () {
      var hpAntes = orc.hp;
      orc.sofrerDano(-5);
      expect(orc.hp, lessThanOrEqualTo(hpAntes));
    });

    // === 8: excecao ===
    test('combate com inimigo null lanca', () {
      expect(() => Combate(jogador, null as dynamic, rng: fake),
             throwsA(isA<TypeError>()));
    });

    // === 9: fake/DI ===
    test('rng controlado faz dano deterministico', () {
      var fake = RandomFalso(valoresDouble: [0.05]);  // critico (< 0.15)
      var c = Combate(jogador, Orc(), rng: fake);
      var antes = c.inimigo.hp;
      c.executarAtaqueJogador();
      expect(c.inimigo.hp, equals(antes - jogador.ataque * 2));  // critico
    });
  });
}

Cada teste é pequeno, focado, isolado. Quando Combate for refatorado (cap. 34 Strategy), os testes garantem que comportamentos antigos não quebraram. Variação: cobertura de código via dart test --coverage - mostra quais linhas são alcançadas pelos testes; meta saudável é 70-80% para código de domínio.


Capítulo 30 - Async, Await e o Tempo na Masmorra

Desafio 30.1 - Carregamento com Retentativa

Implemente uma função carregarArquivoComRetentativa(String caminho, int tentativas) que tenta ler um arquivo até tentativas vezes, aguardando 500ms entre tentativas. Se falhar em todas as tentativas, retorna um fallback vazio.

Solução de referência. Retry pattern é vital para I/O instável (rede caindo, disco ocupado). for com try/catch dentro tenta N vezes; await Future.delayed espaça as tentativas (back-off). Após tentativas falhas, retorna fallback.

import 'dart:io';
import 'dart:async';

Future<String> carregarArquivoComRetentativa(
  String caminho,
  int tentativas,
) async {
  for (var i = 1; i <= tentativas; i++) {
    try {
      var conteudo = await File(caminho).readAsString();
      return conteudo;
    } catch (e) {
      print('Tentativa $i falhou: $e');
      if (i < tentativas) {
        await Future.delayed(Duration(milliseconds: 500));
      }
    }
  }
  return '';  // fallback vazio
}

void main() async {
  var dados = await carregarArquivoComRetentativa('save.json', 3);
  if (dados.isEmpty) {
    print('Carregamento falhou apos 3 tentativas. Iniciando novo jogo.');
  } else {
    print('Carregado: ${dados.length} bytes.');
  }
}

Variação: backoff exponencial (Duration(milliseconds: 500 * (1 << i))) - tentativa 1 espera 500ms, tentativa 2 espera 1s, 3 espera 2s. Útil para rede congestionada. Outra: jitter aleatório (+ rng.nextInt(200)) para evitar “thundering herd” quando muitos clientes retentam ao mesmo tempo.

Desafio 30.2 - Stream Periódico

Crie um FluxoTempoReal que emite eventos a cada 100ms (use Stream.periodic) durante 5 segundos. Inscreva-se, filtre apenas eventos com valor par, e print cada um.

Solução de referência. Stream.periodic emite uma sequência infinita em intervalo fixo. .take(50) limita a 50 eventos (5 segundos / 100ms). .where filtra. .listen consome cada evento.

import 'dart:async';

void main() async {
  var stream = Stream.periodic(
    Duration(milliseconds: 100),
    (i) => i,
  ).take(50);

  await for (var valor in stream.where((v) => v.isEven)) {
    print('Evento par: $valor');
  }
  print('Fluxo terminou.');
}

await for é o jeito idiomático de consumir Stream em código async. Cada iteração espera o próximo evento. Variação: .listen (callback) para consumo sem bloquear; útil quando você quer outras coisas acontecendo em paralelo. Outra: StreamController para criar streams customizadas onde você decide quando emite.

Desafio 30.3 - Carregamento em Paralelo

Implemente Future.wait para carregar 3 recursos em paralelo (mapa, inimigos, itens). Cada um retorna após delay variável (1s, 2s, 1.5s). Meça o tempo total e verifique que é o do mais lento, não a soma.

Solução de referência. Future.wait([f1, f2, f3]) espera todos, mas em paralelo. Tempo total ≈ max(delays), não soma. Crucial para inicialização rápida do jogo.

Future<String> carregarMapa() async {
  await Future.delayed(Duration(seconds: 1));
  return 'Mapa carregado';
}

Future<List<Inimigo>> carregarInimigos() async {
  await Future.delayed(Duration(seconds: 2));
  return [Orc(), Goblin()];
}

Future<List<Item>> carregarItens() async {
  await Future.delayed(Duration(milliseconds: 1500));
  return [Item('Pocao', descricao: '', peso: 100)];
}

void main() async {
  var sw = Stopwatch()..start();
  var resultados = await Future.wait([
    carregarMapa(),
    carregarInimigos(),
    carregarItens(),
  ]);
  sw.stop();
  print('Carregou em ${sw.elapsedMilliseconds}ms');
  print('Total: ${resultados.length} recursos');
}

Saída: ~2000ms (o mais lento, inimigos). Em série (sequencial), seria 1000 + 2000 + 1500 = 4500ms. Variação: Future.any retorna o primeiro que termina (race condition controlada); útil para “qualquer servidor disponível”. Outra: lidar com falhas - se um Future lança, Future.wait propaga; use eagerError: false para coletar resultados parciais.

Desafio 30.4 - Bus de Eventos com Histórico

Crie um BusEventos com histórico. Quando você pede os últimos N eventos, retorna uma lista. Emita 10 eventos e recupere os últimos 5.

Solução de referência. BusEventos com histórico é StreamController com circular buffer. Cada publicar adiciona ao buffer e emite no stream. ultimos(N) retorna o que está no buffer.

import 'dart:async';

class BusEventos<T> {
  final int capacidadeHistorico;
  final _controller = StreamController<T>.broadcast();
  final List<T> _historico = [];

  BusEventos({this.capacidadeHistorico = 50});

  Stream<T> get stream => _controller.stream;

  void publicar(T evento) {
    _historico.add(evento);
    while (_historico.length > capacidadeHistorico) {
      _historico.removeAt(0);
    }
    _controller.add(evento);
  }

  List<T> ultimos(int n) {
    if (n >= _historico.length) return List.from(_historico);
    return _historico.sublist(_historico.length - n);
  }

  void fechar() => _controller.close();
}

void main() {
  var bus = BusEventos<String>();
  for (var i = 0; i < 10; i++) {
    bus.publicar('Evento #$i');
  }

  print('Ultimos 5: ${bus.ultimos(5)}');
  // [Evento #5, #6, #7, #8, #9]

  bus.fechar();
}

broadcast() permite múltiplos listeners; sem ele, StreamController aceita só um. Variação: histórico por tipo (Map<Type, List<T>> interno) para filtrar histórico por classe de evento. Outra: persistir histórico em arquivo - cada publicar também escreve em log; útil para debugging post-mortem.

Desafio 30.5 - Tratamento de Erros Assíncronos

Implemente tratamento de erro assíncrono onde uma operação pode falhar com três exceções diferentes. Use catch (e) específico para cada uma, com fallback apropriado para cada tipo.

Solução de referência. Em async, try/catch funciona como em síncrono: await operacao() lança se a Future falhar. on Tipo catch (e) filtra por exceção; catch (e) é catch-all (use por último).

class ErroRede implements Exception {}
class ErroPermissao implements Exception {}
class ErroFormato implements Exception {}

Future<Map<String, dynamic>> carregarConfig() async {
  try {
    var conteudo = await File('config.json').readAsString();
    return jsonDecode(conteudo) as Map<String, dynamic>;
  } on FileSystemException catch (e) {
    if (e.message.contains('permission')) {
      print('Sem permissao para ler config. Usando defaults.');
      throw ErroPermissao();
    }
    print('Arquivo nao encontrado. Usando defaults.');
    return _configPadrao();
  } on FormatException {
    print('Config corrompido. Usando defaults.');
    return _configPadrao();
  } catch (e) {
    print('Erro desconhecido: $e. Usando defaults.');
    return _configPadrao();
  }
}

Map<String, dynamic> _configPadrao() => {'hp_inicial': 100};

A ordem dos on importa: do mais específico ao mais genérico. catch (e) no fim é safety net. Variação: registrar erro num serviço de telemetria (Sentry, Crashlytics) antes do fallback - assim você sabe que erros acontecem em produção, mesmo que tratados.

Desafio 30.6 - Soma em Paralelo

Crie uma função correrEmParalelo(List<Future<int>> futures) que executa todas em paralelo com Future.wait e retorna a soma de todos os resultados.

Solução de referência. Combinar Future.wait com reduce/fold para agregação é padrão “scatter-gather” - dispara tarefas em paralelo, agrega resultados.

Future<int> correrEmParalelo(List<Future<int>> futures) async {
  var resultados = await Future.wait(futures);
  return resultados.fold<int>(0, (soma, n) => soma + n);
}

Future<int> calcularXp(Inimigo i) async {
  await Future.delayed(Duration(milliseconds: 200));
  return i.maxHp * 2;
}

void main() async {
  var inimigos = [Orc(), Goblin(), Esqueleto(), Dragao()];
  var xpTotal = await correrEmParalelo(
    inimigos.map(calcularXp).toList(),
  );
  print('XP total da run: $xpTotal');
}

Quatro chamadas de 200ms em paralelo = ~200ms total. Em série, 800ms. Variação: usar Stream.fromFutures + await for que processa cada Future conforme termina - útil quando você quer mostrar progresso em vez de só esperar tudo. Outra: cuidado com limites - 1000 Futures em paralelo pode esgotar recursos; use Pool (package pool) para limitar concorrência.

Desafio 30.7 - Registro com Timestamps

Implemente um RegistroEventos que armazena cada evento emitido com timestamp. Adicione método gerarRelatorio() que imprime todos os eventos em ordem cronológica com delta de tempo entre eles.

Solução de referência. Timestamps + deltas tornam o log diagnóstico: você vê não só o que aconteceu, mas quando - e em que ritmo. DateTime.now() no momento da gravação; difference entre eventos adjacentes calcula o delta.

class RegistroEvento {
  final DateTime timestamp;
  final String descricao;
  RegistroEvento(this.descricao) : timestamp = DateTime.now();
}

class RegistroEventos {
  final List<RegistroEvento> _eventos = [];

  void registrar(String descricao) {
    _eventos.add(RegistroEvento(descricao));
  }

  String gerarRelatorio() {
    var sb = StringBuffer();
    sb.writeln('=== RELATORIO ===');
    DateTime? anterior;
    for (var e in _eventos) {
      var delta = anterior == null ? Duration.zero : e.timestamp.difference(anterior);
      var deltaTxt = delta.inSeconds > 0
          ? '(+${delta.inSeconds}s)'
          : '(+${delta.inMilliseconds}ms)';
      sb.writeln('[${e.timestamp.toIso8601String().substring(11, 19)}] $deltaTxt ${e.descricao}');
      anterior = e.timestamp;
    }
    return sb.toString();
  }
}

void main() async {
  var r = RegistroEventos();
  r.registrar('Jogo iniciado');
  await Future.delayed(Duration(milliseconds: 300));
  r.registrar('Primeiro inimigo derrotado');
  await Future.delayed(Duration(milliseconds: 1500));
  r.registrar('Andar 2 alcancado');
  print(r.gerarRelatorio());
}

Saída: tempo absoluto + delta. Útil para “performance profiling” leve - se delta entre dois eventos é absurdo, há um gargalo ali. Variação: agrupar por intervalo de tempo (agrupar(Duration(seconds: 10))) - útil para ver picos de atividade.

Boss Final 30.8 - Sistema de Carregamento Completo

Monte um sistema de carregamento de jogo completo: (1) Carrega arquivo JSON do save (com retry e timeout), (2) faz parse JSON (com tratamento de erro), (3) cria jogador a partir do JSON, (4) carrega mapa, inimigos, itens em paralelo, (5) emite EventoJogoCarregado.

Solução de referência. O sistema combina todos os desafios anteriores: retry (30.1), Stream/Bus (30.2/30.4), wait paralelo (30.3), tratamento de erro (30.5), timestamps (30.7). O pipeline lê como uma receita.

class EventoJogoCarregado {
  final Jogador jogador;
  final MapaMasmorra mapa;
  EventoJogoCarregado(this.jogador, this.mapa);
}

Future<EstadoJogo> carregarJogoCompleto(
  String savePath,
  BusEventos<EventoJogoCarregado> bus,
) async {
  // 1. carregar save com retry + timeout
  var saveTexto = await carregarArquivoComRetentativa(savePath, 3)
      .timeout(Duration(seconds: 5));

  // 2. parsear JSON
  Map<String, dynamic> dados;
  try {
    dados = jsonDecode(saveTexto) as Map<String, dynamic>;
  } on FormatException {
    print('Save corrompido. Iniciando novo jogo.');
    dados = {};
  }

  // 3. construir jogador
  var jogador = Jogador.deArquivo(dados);

  // 4. carregar recursos em paralelo
  var resultados = await Future.wait([
    _carregarMapa(),
    _carregarInimigosPadrao(),
    _carregarItensPadrao(),
  ]);
  var mapa = resultados[0] as MapaMasmorra;

  // 5. emitir evento
  bus.publicar(EventoJogoCarregado(jogador, mapa));
  print('Jogo carregado para ${jogador.nome}');

  return EstadoJogo(jogador, [mapa], 0);
}

void main() async {
  var bus = BusEventos<EventoJogoCarregado>();

  // log de eventos enquanto carrega
  bus.stream.listen((e) {
    print('>> Evento: jogo carregado para ${e.jogador.nome}');
  });

  try {
    var estado = await carregarJogoCompleto('save.json', bus);
    print('Pronto para jogar.');
  } on TimeoutException {
    print('Timeout no carregamento.');
  }
}

Cada passo tem seu fallback; o pipeline degrada-se de forma graciosa em vez de cair. Note que o evento é publicado dentro de carregarJogoCompleto, mas o listener já estava ativo - separação clara entre produção e consumo de eventos. Variação: progresso granular (publicar EventoCarregando("save"), EventoCarregando("mapa") etc.) - UI mostra spinner com mensagem específica.


Capítulo 31 - Persistência em JSON

Desafio 31.1 - Seu Primeiro Await

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.

Solução de referência. Função async retorna Future<T> mesmo se você só return T. O await “desempacota” a Future, esperando o resultado sem bloquear o programa inteiro. Para CLI single-thread, isso permite que I/O não trave o event loop.

import 'dart:async';

Future<String> carregarHistoria() async {
  await Future.delayed(Duration(seconds: 1));
  return 'Epopeia carregada';
}

void main() async {
  print('Iniciando...');
  var resultado = await carregarHistoria();
  print(resultado);
  print('Pronto.');
}

A sequência: “Iniciando…” → 1s de espera → “Epopeia carregada” → “Pronto.”. Variação: chamar duas vezes em paralelo (await Future.wait([carregarHistoria(), carregarHistoria()])) - mesma duração total de 1s. Outra: usar then() em vez de await para encadeamento - mais antigo, menos legível, raramente justificado em Dart moderno.

Desafio 31.2 - Serializar e Reconstruir

Implemente toJson() e fromJson() em Jogador. Crie um jogador, serialize, reconstrua, valide que estão iguais.

Solução de referência. toJson() retorna Map<String, dynamic>; jsonEncode transforma em string. jsonDecode faz o inverso. fromJson é factory que reconstrói a instância. Validar igualdade exige == por valor (cap. 17 desafio 17.5).

import 'dart:convert';

class Jogador {
  String nome;
  int hp, maxHp, ataque, ouro, nivel;

  Jogador({
    required this.nome,
    this.hp = 100, this.maxHp = 100,
    this.ataque = 5, this.ouro = 0, this.nivel = 1,
  });

  Map<String, dynamic> toJson() => {
    'nome': nome,
    'hp': hp, 'max_hp': maxHp,
    'ataque': ataque, 'ouro': ouro, 'nivel': nivel,
  };

  factory Jogador.fromJson(Map<String, dynamic> j) => Jogador(
    nome: j['nome'] as String,
    hp: j['hp'] as int,
    maxHp: j['max_hp'] as int,
    ataque: j['ataque'] as int,
    ouro: j['ouro'] as int,
    nivel: j['nivel'] as int,
  );

  @override
  bool operator ==(Object o) =>
      o is Jogador &&
      o.nome == nome && o.hp == hp && o.maxHp == maxHp &&
      o.ataque == ataque && o.ouro == ouro && o.nivel == nivel;

  @override
  int get hashCode => Object.hash(nome, hp, maxHp, ataque, ouro, nivel);
}

void main() {
  var original = Jogador(nome: 'Aldric', hp: 80, ataque: 7, ouro: 250, nivel: 3);
  var texto = jsonEncode(original.toJson());
  print('Serializado: $texto');

  var reconstruido = Jogador.fromJson(jsonDecode(texto) as Map<String, dynamic>);
  assert(original == reconstruido, 'Reconstrucao falhou');
  print('OK: round-trip preservou o jogador.');
}

Convenção snake_case nos JSON keys (max_hp) vs camelCase no Dart (maxHp) é decisão de equipe; consistência é o que importa. Variação: freezed + json_serializable gera tudo isso automaticamente - menos código manual, mais build steps. Outra: incluir versão ('_v': 2) no JSON para suportar migração futura.

Desafio 31.3 - Salve em Disco

Salve o jogador num arquivo .json e recarregue. Use File de dart:io.

Solução de referência. File.writeAsString salva texto; File.readAsString lê. Ambos têm versões assíncronas (writeAsString retorna Future). Para CLI simples, sync também funciona (writeAsStringSync), com menos código mas bloqueando o thread.

import 'dart:io';
import 'dart:convert';

Future<void> salvarJogador(Jogador j, String caminho) async {
  var texto = jsonEncode(j.toJson());
  await File(caminho).writeAsString(texto);
  print('Salvo em $caminho (${texto.length} bytes)');
}

Future<Jogador?> carregarJogador(String caminho) async {
  var f = File(caminho);
  if (!await f.exists()) return null;
  var texto = await f.readAsString();
  return Jogador.fromJson(jsonDecode(texto) as Map<String, dynamic>);
}

void main() async {
  var j = Jogador(nome: 'Aldric', hp: 80);
  await salvarJogador(j, 'save.json');

  var reconstruido = await carregarJogador('save.json');
  print('Carregado: ${reconstruido?.nome}, HP=${reconstruido?.hp}');
}

Retornar Jogador? em vez de Jogador cobre o caso “arquivo não existe” - jogador novo. Variação: writeAsString com mode: FileMode.append adiciona ao fim em vez de sobrescrever - útil para log. Outra: comprimir antes de salvar (zlib.encode(utf8.encode(texto))) - economiza espaço, irrelevante para saves pequenos.

Desafio 31.4 - Múltiplos Saves

Crie sistema de saves nomeados: “slot1.json”, “slot2.json”, “slot3.json”. Liste saves existentes, carregue por nome.

Solução de referência. Saves nomeados são UX clássica: jogador escolhe slot, carrega/sobrescreve. Listar saves = iterar diretório, filtrar por extensão. Directory.list() retorna Stream para grande quantidade; para 3 slots, listSync é suficiente.

class GerenciadorSaves {
  final String diretorio;
  GerenciadorSaves({this.diretorio = 'saves'});

  String _caminhoSlot(String nome) => '$diretorio/$nome.json';

  Future<void> salvarSlot(String nome, Jogador j) async {
    var dir = Directory(diretorio);
    if (!await dir.exists()) await dir.create(recursive: true);
    await salvarJogador(j, _caminhoSlot(nome));
  }

  Future<Jogador?> carregarSlot(String nome) async {
    return carregarJogador(_caminhoSlot(nome));
  }

  List<String> listarSaves() {
    var dir = Directory(diretorio);
    if (!dir.existsSync()) return [];
    return dir
        .listSync()
        .whereType<File>()
        .where((f) => f.path.endsWith('.json'))
        .map((f) => f.uri.pathSegments.last.replaceAll('.json', ''))
        .toList();
  }

  Future<bool> deletarSlot(String nome) async {
    var f = File(_caminhoSlot(nome));
    if (!await f.exists()) return false;
    await f.delete();
    return true;
  }
}

void main() async {
  var g = GerenciadorSaves();
  await g.salvarSlot('slot1', Jogador(nome: 'Aldric'));
  await g.salvarSlot('slot2', Jogador(nome: 'Iris'));

  print('Saves: ${g.listarSaves()}');  // [slot1, slot2]
  var j = await g.carregarSlot('slot1');
  print('Slot 1: ${j?.nome}');
}

Variação: metadata de cada save (data de criação, nível atingido) num arquivo index.json no diretório de saves - lista de saves vira tabela com mais info. Outra: backup automático (slot1.json.backup) antes de sobrescrever - protege contra crash durante save.

Boss Final 31.5 - Auto-Save Mágico

Implemente auto-save: a cada N turnos ou ao mudar de andar, salva automaticamente no slot “auto”. Sem perder progresso por crash.

Solução de referência. Auto-save é a rede de segurança que evita “perdi 2 horas porque o jogo travou”. A regra é “salvar em momentos seguros” (entre turnos, entre andares) - nunca no meio de operação. Slot dedicado “auto” não interfere com saves manuais.

class AutoSave {
  final GerenciadorSaves saves;
  final int turnosEntreSaves;
  int _turnosDesdeUltimoSave = 0;

  AutoSave(this.saves, {this.turnosEntreSaves = 10});

  Future<void> talvezSalvar(Jogador j, {bool forcado = false}) async {
    _turnosDesdeUltimoSave++;
    if (!forcado && _turnosDesdeUltimoSave < turnosEntreSaves) return;

    try {
      await saves.salvarSlot('auto', j);
      _turnosDesdeUltimoSave = 0;
      print('[Auto-save]');
    } catch (e) {
      print('[Auto-save falhou: $e]');
      // nao reseta contador para tentar de novo no proximo turno
    }
  }

  Future<void> aoMudarAndar(Jogador j) async {
    await talvezSalvar(j, forcado: true);
  }
}

void main() async {
  var saves = GerenciadorSaves();
  var auto = AutoSave(saves, turnosEntreSaves: 5);
  var j = Jogador(nome: 'Aldric');

  for (var turno = 1; turno <= 20; turno++) {
    print('Turno $turno');
    if (turno == 10) {
      print('-- Mudou de andar --');
      await auto.aoMudarAndar(j);
    } else {
      await auto.talvezSalvar(j);
    }
  }
}

O try/catch é essencial: se o auto-save falhar (disco cheio, permissão), o jogo continua jogável. Variação: salvar em arquivo temporário e renomear (saves/auto.json.tmpsaves/auto.json) para garantir atomicidade - se crashar no meio, arquivo final permanece íntegro. Outra: dois slots de auto-save (auto1 e auto2) alternados - se um corromper, outro está OK.


Capítulo 32 - Organização de Projeto: lib/, test/, pubspec.yaml

Desafio 32.1 - Arquitetura Profissional

Reorganize o projeto em estrutura padrão Dart: bin/, lib/, lib/src/, test/, pubspec.yaml. Documente o propósito de cada pasta.

Solução de referência. A convenção do Dart segue regras simples: bin/ para scripts executáveis (dart run bin/main.dart), lib/ para código importável, lib/src/ para código interno (não exportado), test/ para testes. pubspec.yaml é a “package.json do Dart”.

masmorra/
├── pubspec.yaml         # nome, versao, dependencias
├── bin/
│   └── masmorra.dart    # ponto de entrada (void main())
├── lib/
│   ├── masmorra.dart    # exports publicos
│   └── src/
│       ├── jogo/        # codigo interno, nao exportado
│       │   └── jogo.dart
│       ├── mundo/
│       │   └── mapa.dart
│       └── ui/
│           └── tela.dart
├── test/
│   ├── jogo_test.dart
│   └── mundo_test.dart
└── README.md

pubspec.yaml:

name: masmorra
description: Roguelike ASCII em Dart
version: 1.0.0

environment:
  sdk: ^3.0.0

dependencies:
  args: ^2.4.0

dev_dependencies:
  test: ^1.24.0
  lints: ^2.1.0

A convenção lib/src/ para “privado ao package” é seguida pelo SDK Dart - se você publicar como pacote, código em lib/src/ é importado só por outros arquivos dentro do mesmo package. lib/masmorra.dart no topo é o “barrel file” que re-exporta APIs públicas.

Desafio 32.2 - Reorganizar com Cuidado

Mova classes existentes para suas pastas corretas. Ajuste imports. Compile (dart analyze) sem erros.

Solução de referência. Refactor “Move File” requer ajuste de cada import. A regra: import 'package:masmorra/src/...' para arquivos dentro do mesmo package; import 'dart:io' para libs do SDK; import 'package:test/test.dart' para deps externas.

// lib/masmorra.dart (barrel)
export 'src/jogo/jogo.dart';
export 'src/mundo/mapa.dart';
export 'src/personagens/jogador.dart';

// bin/masmorra.dart
import 'package:masmorra/masmorra.dart';

void main(List<String> args) {
  print('=== MASMORRA ASCII ===');
  Jogo().iniciar();
}

// lib/src/jogo/jogo.dart
import '../mundo/mapa.dart';
import '../personagens/jogador.dart';

class Jogo {
  void iniciar() { /* ... */ }
}

Imports relativos (../mundo/mapa.dart) funcionam dentro de lib/src/ mas se confundem fora. Convenção saudável: dentro de lib/src/, use relativos; em bin/ e test/, use package:. dart analyze detecta imports quebrados; rode após cada movimento para pegar erros cedo.

Desafio 32.3 - Metadados do Projeto

Escreva pubspec.yaml completo com nome, descrição, versão, autor, licença. Adicione dependencies para args, path, test.

Solução de referência. pubspec.yaml é o cartão de visita do package. Campos name, version (semver) e environment.sdk são obrigatórios. description é exigida se publicar em pub.dev. repository, homepage, issue_tracker ajudam descoberta.

name: masmorra
description: |
  Roguelike ASCII em Dart inspirado em NetHack.
  Movimentos em turnos, geracao procedural, combate por dado.

version: 1.0.0
homepage: https://masmorra.io
repository: https://github.com/kleberandrade/masmorra-ascii-dart
issue_tracker: https://github.com/kleberandrade/masmorra-ascii-dart/issues

environment:
  sdk: ^3.0.0

dependencies:
  args: ^2.4.0          # parsing de argumentos de linha de comando
  path: ^1.8.0           # manipulacao de paths cross-platform

dev_dependencies:
  test: ^1.24.0          # framework de testes
  lints: ^2.1.0          # linting rules oficiais
  build_runner: ^2.4.0   # geracao de codigo (se usar)

executables:
  masmorra: masmorra  # cria comando `dart run masmorra:masmorra`

A seção executables é opcional - registra binários quando o package for instalado globalmente (dart pub global activate masmorra). Variação: flutter_test em vez de test se for Flutter; analyzer direto para análise customizada. Para package privado (não publicado), publish_to: none impede upload acidental.

Desafio 32.4 - Ponto de Entrada Limpo

bin/masmorra.dart deve ser mínimo: 10 linhas no máximo. Lógica vai para lib/.

Solução de referência. Entry point fino é princípio de separação: main() faz parsing de argumentos e delega para classes em lib/. Testar main() é difícil; testar classe Jogo.iniciar() é trivial. Mover a lógica para lib/ aumenta cobertura de teste.

// bin/masmorra.dart - tudo o que precisa estar aqui
import 'package:args/args.dart';
import 'package:masmorra/masmorra.dart';

Future<void> main(List<String> args) async {
  var parser = ArgParser()
    ..addOption('seed', help: 'Semente do RNG')
    ..addFlag('debug', defaultsTo: false);

  var resultado = parser.parse(args);
  var config = ConfiguracaoJogo(
    seed: int.tryParse(resultado['seed'] ?? ''),
    debug: resultado['debug'] as bool,
  );

  await Jogo(config).iniciar();
}

Toda a lógica - escolher seed, criar mapa, rodar loop - vive em Jogo (classe em lib/). Variação: separar parsing de argumentos numa função própria (ArgParser parseArgs(List<String> args)) testável de forma isolada. Para CLI mais elaboradas, cli_completion ou dart_console adicionam recursos.

Boss Final 32.5 - Pronto para Produção

Configure CI no GitHub Actions: roda dart analyze, dart test, dart format --output=none --set-exit-if-changed .. Toda PR deve passar.

Solução de referência. CI (Continuous Integration) verifica cada PR antes de merge - garantia de que o código está saudável. Os três checks (analyze, test, format) cobrem: erros estáticos, regressões funcionais, estilo consistente.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dart-lang/setup-dart@v1
        with:
          sdk: stable

      - name: Install deps
        run: dart pub get

      - name: Verify format
        run: dart format --output=none --set-exit-if-changed .

      - name: Analyze
        run: dart analyze --fatal-infos

      - name: Test
        run: dart test --coverage=coverage
        # opcional: upload coverage para codecov

O --set-exit-if-changed faz dart format falhar se algo precisaria ser formatado - força estilo consistente. --fatal-infos no analyze trata avisos como erros (estrito). --coverage=coverage gera arquivo de cobertura para análise posterior.

Para o analysis_options.yaml:

include: package:lints/recommended.yaml

linter:
  rules:
    - prefer_const_constructors
    - prefer_final_locals
    - unawaited_futures
    - avoid_print  # log em vez de print em producao

Variação: adicionar matriz de versões Dart (strategy.matrix.sdk: [stable, beta, dev]) para garantir compatibilidade futura. Outra: deploy automático ao merge na main (publish em pub.dev, criar release no GitHub) - “continuous deployment”. Para projeto privado, basta CI; para package público, CD economiza trabalho repetitivo.


Capítulo 33 - Testes Golden e HUD ASCII Polido

Desafio 33.1 - Padrão de Ouro

“Golden test” salva uma saída esperada num arquivo. Próximas execuções comparam contra o arquivo. Se diferir, o teste falha. Implemente para renderizarMapa().

Solução de referência. Golden tests são para output visual onde “está certo” é difícil de descrever em código. Você renderiza uma vez, salva como expected.txt, depois cada teste compara byte a byte. Quando o golden quebra, você lê o diff e decide: bug ou nova feature?

// test/golden_test.dart
import 'dart:io';
import 'package:test/test.dart';
import 'package:masmorra/masmorra.dart';

void main() {
  group('Golden tests', () {
    test('renderizar mapa pequeno', () {
      var mapa = MapaMasmorra(10, 5);
      // popula mapa de forma deterministica...
      var atual = renderizarMapa(mapa);

      var goldenFile = File('test/goldens/mapa_pequeno.txt');
      if (!goldenFile.existsSync() || Platform.environment['UPDATE_GOLDEN'] == '1') {
        goldenFile.parent.createSync(recursive: true);
        goldenFile.writeAsStringSync(atual);
        print('Golden atualizado: ${goldenFile.path}');
        return;
      }

      var esperado = goldenFile.readAsStringSync();
      expect(atual, equals(esperado));
    });
  });
}

A flag UPDATE_GOLDEN=1 dart test regenera os arquivos golden (use após mudança intencional). Sem a flag, dart test apenas compara. Variação: comparação tolerante (ignora trailing whitespace, normaliza line endings) - útil entre Linux/Windows. Outra: golden em SVG/PNG para renders gráficos (flutter_test tem matchesGoldenFile nativo).

Desafio 33.2 - Artesão de Barras

Crie uma função barraVisual(int valor, int max, {int largura = 20, String corCheia = "█", String corVazia = "░"}) que monta barra customizável.

Solução de referência. Função generalizada de barra serve para HP, XP, mana, estamina - qualquer “valor / max”. Aceitar caracteres por parâmetro permite identidade visual diferente por contexto. Truncar valor no clamp evita visual quebrado se valor > max.

String barraVisual(
  int valor,
  int max, {
  int largura = 20,
  String corCheia = '█',
  String corVazia = '░',
}) {
  if (max <= 0) return corVazia * largura;
  var razao = valor.clamp(0, max) / max;
  var cheias = (razao * largura).round();
  var vazias = largura - cheias;
  return corCheia * cheias + corVazia * vazias;
}

void main() {
  print('HP:    ${barraVisual(75, 100)}  75/100');
  print('XP:    ${barraVisual(450, 1000, corCheia: '▓', corVazia: '·')}  450/1000');
  print('Mana:  ${barraVisual(25, 50, largura: 10, corCheia: '*')}  25/50');
}

Variação: gradiente de cor (barraVisual(..., gradiente: [verde, amarelo, vermelho]) muda cor conforme percentual) - útil para HP que escurece quando crítico. Outra: animação de “perda” - exibir barra antiga e nova ao mesmo tempo durante 200ms (barraDupla(antes, agora)).

Desafio 33.3 - HUD que Respira

O cursor pisca, a barra de HP “respira” (gradiente lento), o tempo da run conta em tempo real. HUD vivo.

Solução de referência. “HUD vivo” exige refresh periódico - re-renderizar tela a cada N ms mesmo sem ação do jogador. Em CLI, isso é Timer.periodic em background; cuidado para não atrapalhar input do usuário.

import 'dart:async';

class HudVivo {
  Timer? _timer;
  int _frame = 0;
  final Stopwatch cronometro = Stopwatch()..start();

  void iniciar() {
    _timer = Timer.periodic(Duration(milliseconds: 500), (_) {
      _frame++;
      _redesenhar();
    });
  }

  void _redesenhar() {
    var cursor = _frame % 2 == 0 ? '▮' : ' ';
    var pulse = _frame % 4;
    var hpChar = ['█', '▓', '█', '▓'][pulse];
    var t = cronometro.elapsed;
    var tempo = '${t.inMinutes.toString().padLeft(2, "0")}:${(t.inSeconds % 60).toString().padLeft(2, "0")}';

    // limpa linha anterior e redesenha
    stdout.write('\r');  // carriage return
    stdout.write('HP $hpChar$hpChar$hpChar  Tempo: $tempo  $cursor');
  }

  void parar() {
    _timer?.cancel();
    cronometro.stop();
  }
}

void main() async {
  var hud = HudVivo();
  hud.iniciar();
  await Future.delayed(Duration(seconds: 5));
  hud.parar();
  print('\nFim.');
}

stdout.write('\r') retorna o cursor ao início da linha sem nova linha - permite sobrescrever. Limitação: outras coisas printadas no terminal quebram o layout. Variação avançada: dart:io+ANSI cursor positioning (\x1B[H home, \x1B[<L>;<C>H linha/coluna) para HUD em região fixa enquanto o resto do terminal scrolla.

Desafio 33.4 - Galeria de Cenários

Gere screenshots ASCII de diferentes cenários (sala vazia, combate, loja) e organize em screenshots/. Sirva como portfolio do jogo.

Solução de referência. Screenshots ASCII são arquivos texto - mais leves que imagens, copyáveis. Função que gera cenário “sintético” (mapa pré-definido, estado pré-definido) e exporta para arquivo. README pode incluí-los inline.

void gerarGaleria() {
  var dir = Directory('screenshots');
  if (!dir.existsSync()) dir.createSync(recursive: true);

  // cenario 1: sala vazia
  var sala = MapaMasmorra(20, 10);
  // popula bordas e chao
  File('screenshots/01_sala_vazia.txt').writeAsStringSync(
    renderizarMapa(sala),
  );

  // cenario 2: combate
  var sala2 = MapaMasmorra(20, 10);
  sala2.entidades.add(Orc()..x = 5..y = 5);
  var jogador = Jogador(nome: 'Aldric')..x = 7..y = 5;
  File('screenshots/02_combate.txt').writeAsStringSync(
    renderizarMapaCom(sala2, jogador),
  );

  // cenario 3: loja
  var loja = LojaDinamica(
    ouro: 500,
    economia: Economia(),
    estoque: inventarioBase(),
  );
  File('screenshots/03_loja.txt').writeAsStringSync(
    renderizarLoja(loja),
  );

  print('Gerados 3 screenshots em screenshots/');
}

Embedar no README: \“textblock com paste do screenshot. Para uma "vitrine" visual interessante. Variação: gerar GIF animado de uma run completa (15 turnos = 15 screenshots em sequência) usandoconvert(ImageMagick) ouagg` para GIFs ASCII reais.

Desafio 33.5 - Refatoração Auditada

Antes e depois de uma refatoração grande, rode todos os testes + golden tests. Se algum falha, ou a refatoração quebrou comportamento, ou o teste estava errado.

Solução de referência. Refactor “auditado” é o oposto de “refactor sem testes”: com testes verdes antes, mexo no código, rodo testes de novo. Se algum quebra, eu sei exatamente o que regrediu. Sem testes, eu acho que está OK.

# 1. confirma verde antes
dart test
echo "===> Antes: tudo verde"

# 2. faz refactor (ex: extrair funcao, renomear classe)
# (edita codigo)

# 3. roda testes de novo
dart test
echo "===> Depois: tudo verde = refactor OK"

# 4. se golden quebrou propositalmente, atualiza
# UPDATE_GOLDEN=1 dart test

A regra é: um refactor por vez. Misturar “extrai método” com “muda parâmetro” causa caos se algo quebrar - você não sabe qual mudança causou. Variação: usar git bisect para identificar exatamente em qual commit um teste passou a falhar - automação de “qual mudança quebrou?”.

Boss Final 33.6 - Progressão Cinematográfica

Crie uma cinemática: do menu até o primeiro turno, com fades e mensagens narrativas. Use sleep e clear screen.

Solução de referência. Cinemática em CLI é sequência cronometrada: print → sleep → print. Para efeito “fade”, use cores ANSI passando do escuro ao claro (\u001B[30m\u001B[37m\u001B[97m). Clear screen entre cenas dá impacto.

import 'dart:io';

void clearScreen() => stdout.write('\u001B[2J\u001B[H');

void fadeIn(String texto, {int passos = 4}) {
  var cores = ['\u001B[30m', '\u001B[90m', '\u001B[37m', '\u001B[97m'];
  for (var i = 0; i < passos; i++) {
    stdout.write('\r${cores[i]}$texto\u001B[0m');
    sleep(Duration(milliseconds: 200));
  }
  stdout.writeln();
}

void cinematica() {
  clearScreen();
  sleep(Duration(milliseconds: 500));

  // cena 1
  fadeIn('Em uma terra esquecida...');
  sleep(Duration(seconds: 1));

  fadeIn('Uma masmorra antiga aguarda.');
  sleep(Duration(seconds: 1));

  clearScreen();
  fadeIn('Voce e Aldric, o ultimo Pesquisador.');
  sleep(Duration(seconds: 2));

  clearScreen();

  // cena 2: aparece o mapa
  for (var passos = 1; passos <= 10; passos++) {
    clearScreen();
    print('Carregando masmorra... $passos/10');
    sleep(Duration(milliseconds: 200));
  }

  clearScreen();
  print('=== JOGO INICIADO ===');
}

void main() {
  cinematica();
}

A combinação fade + clear + sleep dá ritmo. Variação: música/SFX por terminal beep (stdout.write('\x07')) - bisonho mas funciona. Para áudio real, package audioplayers (mas só em apps gráficos, não CLI). Outra: skip da cinemática com qualquer tecla - precisaria de dart:io em modo raw, que complica.


Capítulo 34 - Strategy e Command: Inimigos que Pensam

Desafio 34.1 - Estratégia IASuicida

Implemente uma estratégia IASuicida que sempre avança em direção ao jogador até ficar ao seu lado e então “explode” causando dano em área.

Solução de referência. Strategy desacopla “como decidir o movimento” do inimigo. O suicida tem comportamento simples mas memorável: vai reto, explode quando perto. A explosão emite dano em raio + remove o inimigo do mapa.

abstract class IA {
  void agir(Inimigo inim, EstadoJogo e);
}

class IASuicida extends IA {
  @override
  void agir(Inimigo inim, EstadoJogo e) {
    var j = e.jogador;
    var dist = (inim.x - j.x).abs() + (inim.y - j.y).abs();

    if (dist <= 1) {
      _explodir(inim, e);
    } else {
      var dx = (j.x - inim.x).sign;
      var dy = (j.y - inim.y).sign;
      if (dx != 0 && _podeMover(inim.x + dx, inim.y, e)) {
        inim.x += dx;
      } else if (dy != 0 && _podeMover(inim.x, inim.y + dy, e)) {
        inim.y += dy;
      }
    }
  }

  void _explodir(Inimigo inim, EstadoJogo e) {
    print('${inim.nome} EXPLODE!');
    for (var alvo in [e.jogador, ...e.andares[e.andarAtual].entidades.whereType<Inimigo>()]) {
      var d = (alvo.x - inim.x).abs() + (alvo.y - inim.y).abs();
      if (d <= 2 && alvo != inim) {
        if (alvo is Jogador) alvo.sofrerDano(15);
        if (alvo is Inimigo) alvo.hp -= 10;
      }
    }
    inim.hp = 0;  // morre na explosao
  }

  bool _podeMover(int x, int y, EstadoJogo e) {
    var m = e.andares[e.andarAtual];
    return x >= 0 && y >= 0 && x < m.largura && y < m.altura
        && m.grade[y][x] == Tile.chao;
  }
}

Note que a explosão atinge outros inimigos também - emergente daí é estratégia do jogador: deixe o suicida perto de outros para limpá-los de graça. Variação: aviso visual antes (if (dist == 2) print('O suicida começa a brilhar...')) - dá ao jogador chance de fugir.

Desafio 34.2 - Comando AcaoLancarMagia

Implemente um comando AcaoLancarMagia que custa mana (novo atributo do Inimigo) e pode ser desfeito. Crie um GerenciadorComandos que registra comandos executados.

Solução de referência. Command pattern encapsula uma ação como objeto: executar() faz, desfazer() reverte. O gerenciador mantém pilha de comandos executados - undo limita ao topo.

abstract class Comando {
  void executar(EstadoJogo e);
  void desfazer(EstadoJogo e);
  bool get podeDesfazer => true;
}

class AcaoLancarMagia extends Comando {
  final Inimigo lancador;
  final Jogador alvo;
  final int dano;
  final int custoMana;
  int? _danoAplicado;

  AcaoLancarMagia({
    required this.lancador,
    required this.alvo,
    this.dano = 12,
    this.custoMana = 5,
  });

  @override
  void executar(EstadoJogo e) {
    if (lancador.mana < custoMana) {
      print('${lancador.nome} sem mana para magia.');
      return;
    }
    lancador.mana -= custoMana;
    var hpAntes = alvo.hp;
    alvo.sofrerDano(dano);
    _danoAplicado = hpAntes - alvo.hp;
    print('${lancador.nome} lança magia (-${custoMana} mana, -$_danoAplicado HP no alvo).');
  }

  @override
  void desfazer(EstadoJogo e) {
    if (_danoAplicado == null) return;
    alvo.hp += _danoAplicado!;
    lancador.mana += custoMana;
    print('Magia desfeita.');
  }
}

class GerenciadorComandos {
  final List<Comando> _historico = [];

  void executar(Comando c, EstadoJogo e) {
    c.executar(e);
    if (c.podeDesfazer) _historico.add(c);
  }

  bool desfazerUltimo(EstadoJogo e) {
    if (_historico.isEmpty) return false;
    var c = _historico.removeLast();
    c.desfazer(e);
    return true;
  }
}

O _danoAplicado salva o resultado real (caso o HP do alvo já estivesse baixo - dano pode ter sido limitado). Variação: limite na pilha (if (_historico.length > 50) _historico.removeAt(0)) para evitar uso indefinido de memória. Outra: comandos persistentes - serializar pilha para arquivo, recarregar - permite “tornar replay editável”.

Desafio 34.3 - IAPatrulha com Múltiplas Rotas

Modifique IAPatrulha para suportar múltiplas rotas e escolha uma aleatoriamente quando criada ou quando completar a rota atual.

Solução de referência. Patrulha previsível é frustrante após você decorar; múltiplas rotas mantêm o jogador alerta. O inimigo carrega uma lista de rotas; quando completa uma, sorteia próxima.

class IAPatrulha extends IA {
  final List<List<Pos>> rotas;
  final Random rng;
  int _rotaAtual = 0;
  int _passoAtual = 0;

  IAPatrulha(this.rotas, {Random? rng}) : rng = rng ?? Random() {
    _rotaAtual = this.rng.nextInt(rotas.length);
  }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    var rota = rotas[_rotaAtual];
    if (rota.isEmpty) return;
    var alvo = rota[_passoAtual];

    if (inim.x == alvo.x && inim.y == alvo.y) {
      _passoAtual++;
      if (_passoAtual >= rota.length) {
        // completou a rota: sorteia proxima
        _rotaAtual = rng.nextInt(rotas.length);
        _passoAtual = 0;
        print('${inim.nome} muda rota para #$_rotaAtual.');
      }
      return;
    }

    // anda em direcao ao alvo da rota atual
    if (inim.x < alvo.x) inim.x++;
    else if (inim.x > alvo.x) inim.x--;
    else if (inim.y < alvo.y) inim.y++;
    else if (inim.y > alvo.y) inim.y--;
  }
}

A rota é lista de pontos; o inimigo persegue cada um sequencialmente. Variação: prioridade de rota baseada em hora do jogo (rota “ronda noturna” vs “ronda diurna”); ou rotas conectadas (rota A → rota B se condição X). Aproxima de behavior tree (cap. 36).

Desafio 34.4 - AcaoFuga

Implemente AcaoFuga que move o inimigo para uma posição segura; se não encontrar após 5 turnos, o inimigo se rende.

Solução de referência. Fuga é o oposto de perseguir: maximizar distância. Posição “segura” = maior distância Manhattan do jogador, no chão. Após N tentativas, o inimigo desiste - mecânica narrativa: “se rendeu, agora você ganhou XP sem matar”.

class AcaoFuga extends Comando {
  final Inimigo inim;
  int tentativas = 0;
  static const maxTentativas = 5;

  AcaoFuga(this.inim);

  @override
  bool get podeDesfazer => false;

  @override
  void executar(EstadoJogo e) {
    var melhor = _melhorPosicao(e);
    if (melhor != null) {
      inim.x = melhor.x;
      inim.y = melhor.y;
      tentativas = 0;
    } else {
      tentativas++;
      if (tentativas >= maxTentativas) {
        print('${inim.nome} se rende!');
        // marca como rendido; combate o trata como morto, mas concede XP menor
        inim.hp = 0;
      }
    }
  }

  @override
  void desfazer(EstadoJogo e) {}

  Pos? _melhorPosicao(EstadoJogo e) {
    var j = e.jogador;
    var m = e.andares[e.andarAtual];
    Pos? melhor;
    var melhorDist = -1;

    for (var dy = -2; dy <= 2; dy++) {
      for (var dx = -2; dx <= 2; dx++) {
        var nx = inim.x + dx, ny = inim.y + dy;
        if (nx < 0 || ny < 0 || nx >= m.largura || ny >= m.altura) continue;
        if (m.grade[ny][nx] != Tile.chao) continue;
        var dist = (nx - j.x).abs() + (ny - j.y).abs();
        if (dist > melhorDist) {
          melhorDist = dist;
          melhor = Pos(nx, ny);
        }
      }
    }
    return melhor;
  }
}

A busca em raio 2 limita CPU; pathfinding completo (A*) daria fuga melhor mas é mais caro. Variação: “esconder” - se encontrar tile que está fora do FOV do jogador, prefere esse. Outra: rendição condicional - inimigo se rende só se HP < 20%; caso contrário continua lutando.

Boss Final 34.5 - Comportamento Adaptativo

Crie um sistema de “Comportamento Adaptativo” onde um inimigo começa com IAPatrulha e, após sofrer dano, troca para IAAgressiva; se HP < 20%, troca para IAFuga.

Solução de referência. Strategy + estado: o inimigo carrega referência à IA atual, e métodos auxiliares trocam baseado em eventos. É o gérmen das máquinas de estado do cap. 36; aqui, transições explícitas em vez de FSM formal.

class InimigoAdaptativo extends Inimigo {
  IA ia;
  bool jaSofreuDano = false;

  InimigoAdaptativo({required IA iaInicial, required super.nome, required super.hp, required super.maxHp, required super.ataque, required super.simbolo, required super.descricao})
      : ia = iaInicial;

  @override
  void sofrerDano(int valor) {
    var hpAntes = hp;
    super.sofrerDano(valor);
    if (valor > 0 && !jaSofreuDano) {
      jaSofreuDano = true;
      ia = IAAgressiva();  // troca para agressivo
      print('${nome} fica agressivo.');
    }
    if (hp <= maxHp * 0.20 && ia is! AcaoFuga) {
      ia = IAFuga(this);
      print('${nome} comeca a fugir.');
    }
  }
}

class IAAgressiva extends IA {
  @override
  void agir(Inimigo inim, EstadoJogo e) {
    // perseguicao agressiva: anda ate ficar do lado do jogador, ataca
    var j = e.jogador;
    var dx = (j.x - inim.x).sign;
    var dy = (j.y - inim.y).sign;
    var dist = (j.x - inim.x).abs() + (j.y - inim.y).abs();
    if (dist <= 1) {
      j.sofrerDano(inim.ataque);
    } else {
      inim.x += dx;
      inim.y += dy;
    }
  }
}

A transição “uma vez agressivo, sempre agressivo até HP baixo, então foge” é estado implicit - boolean + comparação de HP. Para mais estados (15+), prefira FSM explícito. Variação: voltar para patrulha se sair do FOV do jogador por 10 turnos - simula “esfriar”. A complexidade cresce; cap. 36 formaliza.

Desafio 34.6 - IAMago

Implemente uma estratégia IAMago para um inimigo mago que: lança bola de fogo se mana suficiente, recua se perto, conjura escudo se HP baixo.

Solução de referência. Mago é IA com prioridades. Cada turno avalia condições em ordem: (1) HP baixo → escudo; (2) inimigo perto → recuar; (3) mana suficiente → magia. Cada ramo retorna; só um age.

class IAMago extends IA {
  bool escudoAtivo = false;

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    var j = e.jogador;
    var dist = (inim.x - j.x).abs() + (inim.y - j.y).abs();

    // 1. HP baixo: escudo
    if (inim.hp <= inim.maxHp * 0.30 && !escudoAtivo && inim.mana >= 10) {
      inim.mana -= 10;
      escudoAtivo = true;
      print('${inim.nome} conjura escudo arcano!');
      return;
    }

    // 2. inimigo perto: recuar
    if (dist <= 2) {
      var dx = (inim.x - j.x).sign;
      var dy = (inim.y - j.y).sign;
      inim.x += dx;
      inim.y += dy;
      print('${inim.nome} recua, mantendo distancia.');
      return;
    }

    // 3. mana suficiente: bola de fogo
    if (inim.mana >= 5) {
      inim.mana -= 5;
      var dano = 12;
      j.sofrerDano(dano);
      print('${inim.nome} lança bola de fogo (-$dano HP)!');
      return;
    }

    // 4. fallback: regenerar mana
    inim.mana = (inim.mana + 1).clamp(0, 20);
    print('${inim.nome} medita e recupera mana.');
  }
}

Priorização hierárquica é clara: o mago nunca corpo-a-corpo. Variação: escudo com duração (int turnosEscudo); reduz dano sofrido por X turnos e some. Outra: bola de fogo em área (atinge tiles adjacentes ao alvo) - mais poderosa, gasta mais mana.

Desafio 34.7 - AcaoDesfazerMultiplo

Implemente um comando AcaoDesfazerMultiplo que desfaz as últimas N ações de uma vez. Modifique GerenciadorComandos para suportar.

Solução de referência. Desfazer N ações = pop N vezes da pilha, chamando desfazer em cada. A ordem matters: desfaz da mais recente para a mais antiga, do contrário inverte estado errado.

class GerenciadorComandos {
  final List<Comando> _historico = [];

  void executar(Comando c, EstadoJogo e) {
    c.executar(e);
    if (c.podeDesfazer) _historico.add(c);
  }

  int desfazerUltimos(int n, EstadoJogo e) {
    var desfeitos = 0;
    while (desfeitos < n && _historico.isNotEmpty) {
      var c = _historico.removeLast();
      c.desfazer(e);
      desfeitos++;
    }
    return desfeitos;
  }
}

// uso:
void main() {
  var g = GerenciadorComandos();
  var estado = EstadoJogo(/* ... */);

  g.executar(AcaoMover(direcao: Direcao.norte), estado);
  g.executar(AcaoAtacar(alvo: orc), estado);
  g.executar(AcaoLancarMagia(lancador: mago, alvo: jogador), estado);

  print('Desfazendo 2 acoes...');
  g.desfazerUltimos(2, estado);
  // agora so a primeira acao (mover) esta no estado
}

Para “redo” (refazer), precisaria de segunda pilha onde desfeitos são empilhados; redo move de volta. Padrão clássico de editores de texto. Variação: limitar undo a N acima de checkpoint específico - “não dá para desfazer antes de salvar” - protege estado persistido.

Desafio 34.8 - IACompostaAgressiva

Crie uma estratégia IACompostaAgressiva que combina múltiplas estratégias em sequência: patrulha → alerta → perseguição.

Solução de referência. Composta = lista de IAs com condição de transição entre cada uma. Cada chamada agir delega para a IA atual e checa transições. É essencialmente um state machine simples.

class FaseIA {
  final IA ia;
  final bool Function(Inimigo, EstadoJogo) condicaoParaProxima;
  FaseIA(this.ia, this.condicaoParaProxima);
}

class IACompostaAgressiva extends IA {
  final List<FaseIA> fases;
  int _faseAtual = 0;

  IACompostaAgressiva(this.fases);

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    var fase = fases[_faseAtual];
    fase.ia.agir(inim, e);
    if (_faseAtual < fases.length - 1 && fase.condicaoParaProxima(inim, e)) {
      _faseAtual++;
      print('${inim.nome} passa para fase #$_faseAtual.');
    }
  }
}

void main() {
  var ia = IACompostaAgressiva([
    FaseIA(
      IAPatrulha([[Pos(5, 5), Pos(10, 5)]]),
      (inim, e) => (inim.x - e.jogador.x).abs() + (inim.y - e.jogador.y).abs() <= 5,
    ),
    FaseIA(
      IAAlerta(),  // hipotetica: anda em circulos, gritando
      (inim, e) => (inim.x - e.jogador.x).abs() + (inim.y - e.jogador.y).abs() <= 3,
    ),
    FaseIA(
      IAAgressiva(),
      (_, __) => false,  // estado final
    ),
  ]);
}

A última fase tem condicaoParaProxima = false - estado terminal. Variação: ciclo (após agressiva, volta para patrulha se perdeu o jogador por 10 turnos) - precisa de transição “para trás” ou _faseAtual = 0. Mais limpo: representar como grafo dirigido (próximo cap. 36, FSM).


Capítulo 35 - Factory e Observer: O Mundo Reage

Desafio 35.1 - FabricaSala

Implemente uma FabricaSala que lê definição JSON (tipo de sala, inimigos, loot) e gera uma sala completa.

Solução de referência. Factory recebe especificação (dados) e devolve instância (objeto pronto). Útil quando construção é complexa - aqui, ler JSON, popular grade, espalhar inimigos. Caller obtém sala usável sem conhecer detalhes.

class FabricaSala {
  final Random rng;
  FabricaSala(this.rng);

  Sala criarDeJson(Map<String, dynamic> def) {
    var sala = Sala(
      nome: def['nome'] as String,
      descricao: def['descricao'] as String,
      saidas: Map<String, String>.from(def['saidas'] as Map),
    );

    // popula inimigos
    var listaInimigos = (def['inimigos'] as List?) ?? [];
    for (var nome in listaInimigos) {
      sala.inimigos.add(_criarInimigo(nome as String));
    }

    // popula loot
    var listaLoot = (def['loot'] as List?) ?? [];
    for (var nome in listaLoot) {
      sala.itens.add(_criarItem(nome as String));
    }

    return sala;
  }

  Inimigo _criarInimigo(String nome) {
    switch (nome) {
      case 'orc':       return Orc();
      case 'goblin':    return Goblin();
      case 'esqueleto': return Esqueleto();
      default: throw ArgumentError('Inimigo desconhecido: $nome');
    }
  }

  Item _criarItem(String nome) {
    switch (nome) {
      case 'pocao': return Pocao(nome: 'Pocao', descricao: '', peso: 100, curaHP: 10);
      case 'ouro':  return Item('Ouro', '', 0);
      default: throw ArgumentError('Item desconhecido: $nome');
    }
  }
}

// exemplo de definicao JSON:
final defSalaJson = {
  'nome': 'Cripta Antiga',
  'descricao': 'Ossos espalhados.',
  'saidas': {'oeste': 'corredor'},
  'inimigos': ['orc', 'orc', 'esqueleto'],
  'loot': ['pocao', 'ouro'],
};

void main() {
  var fab = FabricaSala(Random());
  var sala = fab.criarDeJson(defSalaJson);
  print('Criada: ${sala.nome} com ${sala.inimigos.length} inimigos.');
}

Variação: registry pattern - FabricaSala mantém Map<String, Inimigo Function()> que outros módulos preenchem (fab.registrarInimigo('lobo', () => Lobo())). Adicionar tipo novo não exige editar _criarInimigo. Outra: validação JSON com schema antes de processar - falha rápida em dados malformados.

Desafio 35.2 - ObservadorSubidaNivel

Crie um EventoSubirNivel e um ObservadorSubidaNivel que toca som especial, mostra animação e escreve no log.

Solução de referência. Observer recebe eventos do barramento sem o publicador saber quem está escutando. Vários observadores podem reagir ao mesmo evento - cada um faz uma coisa (som, animação, log) - sem ferir SRP.

class EventoSubirNivel {
  final Jogador jogador;
  final int novoNivel;
  EventoSubirNivel(this.jogador, this.novoNivel);
}

abstract class Observador<T> {
  void aoEvento(T evento);
}

class ObservadorSubidaNivel extends Observador<EventoSubirNivel> {
  @override
  void aoEvento(EventoSubirNivel e) {
    // 1. "som"
    stdout.write('\x07');  // beep do terminal

    // 2. animacao
    print('');
    print('  ╔════════════════════╗');
    print('  ║   LEVEL UP!        ║');
    print('  ║   Nivel ${e.novoNivel.toString().padRight(11)}║');
    print('  ╚════════════════════╝');

    // 3. log
    print('[LOG] ${e.jogador.nome} subiu para o nivel ${e.novoNivel}');
  }
}

class BarramentoEventos {
  final List<Observador> _observadores = [];

  void inscrever(Observador obs) {
    _observadores.add(obs);
  }

  void publicar(dynamic evento) {
    for (var obs in _observadores) {
      // dispatch dinamico (Dart nao tem covariancia perfeita aqui)
      try {
        obs.aoEvento(evento);
      } catch (_) {}  // ignora observadores incompatíveis
    }
  }
}

O try/catch em publicar é grosseiro - melhor seria checar obs is Observador<T> antes. Variação: tipagem mais forte com Map<Type, List<Observador>> indexado por classe do evento - dispatch direto sem try/catch. Outra: prioridade entre observadores (int prioridade no observador, ordenar antes de emitir) - útil quando ordem importa.

Desafio 35.3 - ObservadorRegistroCombate

Implemente um ObservadorRegistroCombate que armazena todos os eventos de combate em uma lista, permitindo consultas posteriores.

Solução de referência. Observer que acumula é o cerne do replay. Cada evento entra na lista; consultas filtram/agregam depois.

class ObservadorRegistroCombate extends Observador<EventoCombate> {
  final List<EventoCombate> _registro = [];

  @override
  void aoEvento(EventoCombate e) {
    _registro.add(e);
  }

  int totalDanoCausadoPorJogador() {
    return _registro
        .where((e) => e.alvo != 'Jogador')
        .fold<int>(0, (s, e) => s + e.dano);
  }

  int totalDanoSofrido() {
    return _registro
        .where((e) => e.alvo == 'Jogador')
        .fold<int>(0, (s, e) => s + e.dano);
  }

  Map<String, int> mortesPorTipo() {
    var mortes = <String, int>{};
    for (var e in _registro.where((e) => e.dano > 0 && e.alvo != 'Jogador')) {
      mortes[e.alvo] = (mortes[e.alvo] ?? 0) + 1;
    }
    return mortes;
  }
}

Variação: query em janela temporal (registroNasUltimas(Duration(minutes: 5))) - útil para HUD “intensidade recente”. Outra: persistir o registro em arquivo - replay disponível mesmo após reabrir o jogo. Para registros grandes, banco SQLite local em vez de JSON.

Desafio 35.4 - ObservadorPersistencia

Crie um ObservadorPersistencia que escuta EventoMorteInimigo e atualiza um JSON com estatísticas globais.

Solução de referência. Observador que persiste é integração entre runtime e disco. Cada morte → incrementa contador → grava JSON. Para performance, escrita pode ser debounced (a cada 10 eventos, ou ao fim do andar).

class EventoMorteInimigo {
  final String tipoInimigo;
  EventoMorteInimigo(this.tipoInimigo);
}

class ObservadorPersistencia extends Observador<EventoMorteInimigo> {
  final String caminho;
  Map<String, int> _stats = {};
  int _desdeUltimoFlush = 0;

  ObservadorPersistencia(this.caminho) {
    _carregar();
  }

  void _carregar() {
    var f = File(caminho);
    if (f.existsSync()) {
      _stats = Map<String, int>.from(jsonDecode(f.readAsStringSync()));
    }
  }

  void _flush() {
    File(caminho).writeAsStringSync(jsonEncode(_stats));
    _desdeUltimoFlush = 0;
  }

  @override
  void aoEvento(EventoMorteInimigo e) {
    _stats[e.tipoInimigo] = (_stats[e.tipoInimigo] ?? 0) + 1;
    _desdeUltimoFlush++;
    if (_desdeUltimoFlush >= 10) _flush();
  }

  Map<String, int> ler() => Map.unmodifiable(_stats);
}

Variação: estatísticas mais ricas (hp_total_perdido, tempo_medio_para_matar) - cada uma é um observador. Outra: enviar a um servidor (http.post) em vez de salvar localmente - leaderboard global.

Boss Final 35.5 - Reações em Cadeia

Implemente um sistema de “Reações em Cadeia” onde um evento dispara eventos posteriores (morte → loot drop → notificação).

Solução de referência. Reações em cadeia exigem que observador também publique eventos. O barramento aceita publicação reentrante - cuidado para não causar loops infinitos.

class ObservadorMorteCausaLoot extends Observador<EventoMorteInimigo> {
  final BarramentoEventos bus;
  final Random rng;
  ObservadorMorteCausaLoot(this.bus, this.rng);

  @override
  void aoEvento(EventoMorteInimigo e) {
    // 30% chance de dropar ouro
    if (rng.nextDouble() < 0.30) {
      bus.publicar(EventoDropOuro(e.tipoInimigo, 10 + rng.nextInt(40)));
    }
  }
}

class ObservadorDropMostraNotificacao extends Observador<EventoDropOuro> {
  @override
  void aoEvento(EventoDropOuro e) {
    print('${e.tipoInimigo} dropou ${e.quantidade} de ouro.');
  }
}

class EventoDropOuro {
  final String origem;
  final int quantidade;
  EventoDropOuro(this.origem, this.quantidade);
}

void main() {
  var bus = BarramentoEventos();
  bus.inscrever(ObservadorMorteCausaLoot(bus, Random(42)));
  bus.inscrever(ObservadorDropMostraNotificacao());

  // morte do orc dispara possivel drop, drop dispara notificacao
  bus.publicar(EventoMorteInimigo('Orc'));
}

Cadeia de 3+ eventos é poderosa mas frágil: hierarquia de causas dificulta depuração. Variação: incluir id_correlacao em cada evento para rastrear cadeia (Evento(idOrigem: 'morte_orc_42')). Limitar profundidade da cadeia (maxProfundidade = 5) evita loop infinito acidental.

Desafio 35.6 - ObservadorFiltro

Implemente um ObservadorFiltro que permite observadores se inscreverem apenas em eventos que atendem certa condição.

Solução de referência. Decorator pattern: envelopa outro observador com um predicado. Só repassa eventos que passam no filtro - reduz ruído no observador interno.

class ObservadorFiltro<T> extends Observador<T> {
  final Observador<T> alvo;
  final bool Function(T) filtro;
  ObservadorFiltro(this.alvo, this.filtro);

  @override
  void aoEvento(T evento) {
    if (filtro(evento)) alvo.aoEvento(evento);
  }
}

void main() {
  var registro = ObservadorRegistroCombate();
  var soDanoPesado = ObservadorFiltro<EventoCombate>(
    registro,
    (e) => e.dano > 20,
  );

  var bus = BarramentoEventos();
  bus.inscrever(soDanoPesado);
  // agora registro so recebe combates com dano > 20
}

Variação: encadear filtros (ObservadorFiltro(ObservadorFiltro(...))). Cada um filtra ainda mais. Outra: filtro com transformação (ObservadorMapeia<EventoCombate, int>(obs, (e) => e.dano)) - converte tipo de evento. Aproxima de RxJava/RxDart.

Desafio 35.7 - RegistroEventos em Arquivo

Crie um RegistroEventos que persiste todos os eventos em um arquivo JSON de log.

Solução de referência. Log estruturado é cheia de valor: pós-mortem (por que o jogo crashou?), telemetria (que features são usadas?), replay (reproduzir bug exato). Cada evento serializa-se como linha JSON; arquivo cresce gradualmente.

class RegistroEventos {
  final String caminho;
  final IOSink _sink;

  RegistroEventos(this.caminho)
      : _sink = File(caminho).openWrite(mode: FileMode.append);

  void registrar(String tipo, Map<String, dynamic> dados) {
    var entrada = {
      'ts': DateTime.now().toIso8601String(),
      'tipo': tipo,
      ...dados,
    };
    _sink.writeln(jsonEncode(entrada));
  }

  Future<void> fechar() async {
    await _sink.flush();
    await _sink.close();
  }
}

void main() async {
  var reg = RegistroEventos('eventos.jsonl');
  reg.registrar('jogo_iniciado', {'jogador': 'Aldric'});
  reg.registrar('combate', {'alvo': 'Orc', 'dano': 12});
  reg.registrar('andar_subiu', {'andar': 2});
  await reg.fechar();
}

Formato JSONL (uma JSON por linha) é amigável para parsing incremental - cada linha é independente, ferramenta de log (jq, grep) trabalha bem. Variação: rotação de log (arquivo > 10MB → renomeia para .1, cria novo). Sem isso, log cresce infinitamente.

Desafio 35.8 - FabricaInimigoEvoluida

Implemente uma FabricaInimigoEvoluida que carrega definições não apenas de um JSON estático, mas de múltiplos arquivos, suportando “modding”.

Solução de referência. Modding aceita “pasta de mods” onde cada arquivo .json define inimigos novos. Carregar todos no startup; mods sobrescrevem ou adicionam. Permite à comunidade criar conteúdo sem mexer no código fonte.

class FabricaInimigoEvoluida {
  final Map<String, Map<String, dynamic>> _definicoes = {};

  void carregarPastaMods(String pasta) {
    var dir = Directory(pasta);
    if (!dir.existsSync()) return;

    for (var f in dir.listSync().whereType<File>()) {
      if (!f.path.endsWith('.json')) continue;
      try {
        var dados = jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;
        var defs = (dados['inimigos'] as List?) ?? [];
        for (var def in defs) {
          var d = def as Map<String, dynamic>;
          _definicoes[d['id'] as String] = d;
          print('Carregado: ${d["id"]} de ${f.path.split("/").last}');
        }
      } catch (e) {
        print('Erro carregando ${f.path}: $e');
      }
    }
  }

  Inimigo criar(String id) {
    var def = _definicoes[id];
    if (def == null) throw ArgumentError('Inimigo $id nao definido');
    return Inimigo(
      nome: def['nome'] as String,
      hp: def['hp'] as int,
      maxHp: def['hp'] as int,
      ataque: def['ataque'] as int,
      simbolo: def['simbolo'] as String,
      descricao: def['descricao'] as String,
    );
  }
}

// pasta mods/:
// mods/base.json  -> Orc, Goblin, Esqueleto
// mods/dlc1.json  -> Lich, Vampiro
// mods/userspack.json -> sobreescreve Orc com versao buffada

A ordem de carregamento define precedência (último ganha). Variação: assinatura digital nos mods (signature: “sha256…”) para evitar mods maliciosos. Para roguelike puro single-player, isso é overhead; para community-driven, vital.


Capítulo 36 - Máquinas de Estado: Patrulha, Alerta e Perseguição

Desafio 36.1 - Estado Confuso

Implemente um estado Confuso onde o inimigo anda aleatoriamente durante 5 turnos. Adicione transição automática para o estado anterior após esgotar a confusão.

Solução de referência. Estado em FSM tem três coisas: o que faz (agir), quando termina (condicaoSaida), para qual estado vai (proximoEstado). Aqui, “Confuso” se auto-encerra após 5 turnos, voltando ao estado anterior salvo.

abstract class EstadoIA {
  String get nome;
  void aoEntrar(Inimigo inim) {}
  void agir(Inimigo inim, EstadoJogo e);
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e);
  void aoSair(Inimigo inim) {}
}

class EstadoConfuso extends EstadoIA {
  final EstadoIA estadoAnterior;
  int turnosRestantes = 5;
  final Random rng;

  EstadoConfuso({required this.estadoAnterior, Random? rng})
      : rng = rng ?? Random();

  @override
  String get nome => 'Confuso';

  @override
  void aoEntrar(Inimigo inim) {
    print('${inim.nome} fica confuso!');
  }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    var vizinhos = [
      Pos(inim.x + 1, inim.y), Pos(inim.x - 1, inim.y),
      Pos(inim.x, inim.y + 1), Pos(inim.x, inim.y - 1),
    ]..shuffle(rng);
    for (var v in vizinhos) {
      if (_podeMover(v, e)) {
        inim.x = v.x;
        inim.y = v.y;
        break;
      }
    }
    turnosRestantes--;
  }

  @override
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) {
    return turnosRestantes <= 0 ? estadoAnterior : null;
  }
}

bool _podeMover(Pos p, EstadoJogo e) {
  var m = e.andares[e.andarAtual];
  if (p.x < 0 || p.y < 0 || p.x >= m.largura || p.y >= m.altura) return false;
  return m.grade[p.y][p.x] == Tile.chao;
}

A FSM externa gerencia transições: chama agir e depois consulta proximoEstado - se não-nulo, troca. Variação: estado “Confuso” com chance de ataque acidental (10% de ferir si mesmo a cada turno) - dá personalidade ao status. Outra: efeito visual de partículas (* ao redor do inimigo) para sinalizar visualmente o estado.

Desafio 36.2 - Estado Regenerando

Crie um estado Regenerando para um inimigo especial que, quando sua HP fica baixa, entra em um ciclo de regeneração.

Solução de referência. Estado “Regenerando” cura por turno até HP cheio (ou ataque do jogador interrompe). Transição entrada: HP < 30%; saída: HP cheio OU jogador ataca.

class EstadoRegenerando extends EstadoIA {
  final EstadoIA estadoAnterior;
  int hpInicial;

  EstadoRegenerando({required this.estadoAnterior})
      : hpInicial = 0;

  @override
  String get nome => 'Regenerando';

  @override
  void aoEntrar(Inimigo inim) {
    hpInicial = inim.hp;
    print('${inim.nome} se cura magicamente!');
  }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    inim.hp = (inim.hp + 5).clamp(0, inim.maxHp);
  }

  @override
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) {
    // sai se HP cheio, ou se sofreu dano nesse ciclo
    if (inim.hp >= inim.maxHp) return estadoAnterior;
    if (inim.hp < hpInicial) return estadoAnterior;  // interrompido
    return null;
  }
}

A comparação hp < hpInicial detecta interrupção (jogador atacou). Variação: estado pode ter custo - cada turno regenerando reduz mana; quando mana esgota, sai mesmo se HP não cheio. Outra: regenerar só funciona se inimigo está fora do FOV do jogador (precisa “se esconder para curar”).

Desafio 36.3 - Agressão Escalada

Implemente um sistema de “Agressão Escalada” onde cada hit que você acerta incrementa um contador que faz o inimigo passar para estados mais agressivos.

Solução de referência. Cada hit acumula no contador; transições por limiar. Comum em jogos: “leve” → “irritado” → “enfurecido” → “berserker”. A graça é que o estado escalado dá mais dano mas talvez mais previsível.

class InimigoAgressivoEscalado extends Inimigo {
  int hitsRecebidos = 0;
  EstadoIA estado;

  InimigoAgressivoEscalado({required this.estado, required super.nome, required super.hp, required super.maxHp, required super.ataque, required super.simbolo, required super.descricao});

  @override
  void sofrerDano(int valor) {
    super.sofrerDano(valor);
    if (valor > 0) {
      hitsRecebidos++;
      _atualizarEstadoSeNecessario();
    }
  }

  void _atualizarEstadoSeNecessario() {
    EstadoIA? novo;
    if (hitsRecebidos >= 8) novo = EstadoBerserker();
    else if (hitsRecebidos >= 4) novo = EstadoEnfurecido();
    else if (hitsRecebidos >= 2) novo = EstadoIrritado();

    if (novo != null && novo.nome != estado.nome) {
      estado.aoSair(this);
      estado = novo;
      estado.aoEntrar(this);
    }
  }
}

class EstadoIrritado extends EstadoIA {
  @override String get nome => 'Irritado';
  @override void aoEntrar(Inimigo inim) {
    print('${inim.nome} fica IRRITADO. Ataque +2.');
    inim.ataque += 2;
  }
  @override void agir(Inimigo inim, EstadoJogo e) { /* IA agressiva normal */ }
  @override EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) => null;
}

Cada estado eleva o ataque, mas o jogador pode mudar tática. Variação: estado “berserker” causa dano em si mesmo (hp -= 1 por turno) - acelera derrota se o jogador resistir. Trade-off interessante.

Desafio 36.4 - Estado SaltoEspecial

Crie um estado SaltoEspecial onde um inimigo pula para uma posição aleatória e depois volta.

Solução de referência. Salto = teleporte temporário. Estado salva posição original, move para alvo, retorna após N turnos. Visualmente, o inimigo some e reaparece - cria momentos de “sumiu, onde está?”.

class EstadoSaltoEspecial extends EstadoIA {
  final EstadoIA estadoAnterior;
  Pos? posicaoOriginal;
  int turnoSalto = 0;
  final int duracao = 3;

  EstadoSaltoEspecial({required this.estadoAnterior});

  @override String get nome => 'Salto';

  @override
  void aoEntrar(Inimigo inim) {
    posicaoOriginal = Pos(inim.x, inim.y);
    print('${inim.nome} salta para uma posicao desconhecida!');
  }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    if (turnoSalto == 0) {
      var rng = Random();
      var m = e.andares[e.andarAtual];
      for (var t = 0; t < 30; t++) {
        var x = rng.nextInt(m.largura);
        var y = rng.nextInt(m.altura);
        if (m.grade[y][x] == Tile.chao) {
          inim.x = x;
          inim.y = y;
          break;
        }
      }
    }
    turnoSalto++;
  }

  @override
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) {
    if (turnoSalto >= duracao) {
      // volta para posicao original ao sair
      if (posicaoOriginal != null) {
        inim.x = posicaoOriginal!.x;
        inim.y = posicaoOriginal!.y;
      }
      return estadoAnterior;
    }
    return null;
  }
}

Variação: salto inteligente (em vez de aleatório) - escolhe posição que mantém visão do jogador mas longe de ataque corpo-a-corpo. Outra: cooldown - inimigo só pode usar salto a cada 10 turnos.

Desafio 36.5 - Comportamento Imprevisível

Implemente uma máquina de estado de “Comportamento Imprevisível” onde o boss tem 5 estados (Agressivo, Defensivo, Mago, Furtivo, Berserk) e troca aleatoriamente entre eles.

Solução de referência. Boss com FSM aleatória cria imprevisibilidade - jogador não consegue decorar padrão. A regra: a cada N turnos, sorteia novo estado da lista. Limita a 5 estados para o jogador conseguir reconhecer cada um.

class FSMBossAleatoria {
  final List<EstadoIA> estados;
  final Random rng;
  EstadoIA atual;
  int turnosNoEstado = 0;
  final int trocaA cada;

  FSMBossAleatoria({required this.estados, required this.rng, required this.trocaA cada})
      : atual = estados.first;

  void agir(Inimigo boss, EstadoJogo e) {
    atual.agir(boss, e);
    turnosNoEstado++;
    if (turnosNoEstado >= trocaA cada) {
      _trocarEstado(boss);
    } else {
      var proximo = atual.proximoEstado(boss, e);
      if (proximo != null) atual = proximo;
    }
  }

  void _trocarEstado(Inimigo boss) {
    atual.aoSair(boss);
    var diferente = estados.where((s) => s.nome != atual.nome).toList();
    atual = diferente[rng.nextInt(diferente.length)];
    atual.aoEntrar(boss);
    turnosNoEstado = 0;
  }
}

Cuidado com nomes Dart - usei trocaA cada com espaço só para mostrar; em código real seria trocaACada. Variação: pesos diferentes por estado (Berserk só 10% de chance vs Agressivo 30%) - personaliza o “feel”. Outra: estado depende de HP - quando < 50%, peso Berserk dobra; “boss fica desesperado”.

Desafio 36.7 - Estado Confuso Avançado

Implemente um estado Confuso que: anda aleatoriamente, perde turnos, transiciona para “Lúcido” após 3 turnos.

Solução de referência. Variação do 36.1 - estado com mais comportamento (perder turnos = não atacar, mesmo se jogador ao lado). Transição para estado “Lúcido” (calmo) em vez de voltar ao anterior.

class EstadoLucido extends EstadoIA {
  @override String get nome => 'Lucido';
  @override void aoEntrar(Inimigo inim) { print('${inim.nome} recobra consciencia.'); }
  @override void agir(Inimigo inim, EstadoJogo e) { /* IA agressiva normal */ }
  @override EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) => null;
}

class EstadoConfusoV2 extends EstadoIA {
  int turnos = 0;

  @override String get nome => 'Confuso';
  @override void aoEntrar(Inimigo inim) {
    print('${inim.nome} fica confuso e perde sua sequencia.');
  }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    // 50% chance de perder o turno
    if (Random().nextBool()) {
      print('${inim.nome} olha em volta, sem foco.');
      turnos++;
      return;
    }
    // anda aleatoriamente (igual 36.1)
    turnos++;
  }

  @override
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) {
    return turnos >= 3 ? EstadoLucido() : null;
  }
}

Variação: confusão “cura-se” mais rápido se o inimigo não sofrer dano (if (turnoSemDano) turnos += 2) - jogador escolhe entre atacar (mantém confusão) ou esperar (passa rápido).

Desafio 36.8 - VigiaEspecial

Crie um estado VigiaEspecial que transiciona automaticamente para Confuso.

Solução de referência. Vigia patrulha; quando vê jogador, fica “atordoado” (Confuso); após sair da confusão, volta para vigia. Demonstra ciclo entre 3 estados.

class EstadoVigiaEspecial extends EstadoIA {
  @override String get nome => 'Vigia';
  @override void aoEntrar(Inimigo inim) { print('${inim.nome} vigia atentamente.'); }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    // patrulha simples
  }

  @override
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) {
    var j = e.jogador;
    var dist = (inim.x - j.x).abs() + (inim.y - j.y).abs();
    if (dist <= 5 && e.andares[e.andarAtual].visivelAgora[inim.y][inim.x]) {
      return EstadoConfusoV2();  // transicao automatica
    }
    return null;
  }
}

Variação: vigia toca um alarme antes de ficar confuso - emite evento que outros inimigos escutam e vão atacar. Cap. 35 (Observer) integra com cap. 36 (FSM).

Desafio 36.9 - BossEmFuria

Implemente um estado BossEmFuria que dobra o ataque, ganha velocidade extra e tem 50% de chance de ataque duplo.

Solução de referência. Furia tem múltiplos buffs ativados ao entrar (aoEntrar) e revertidos ao sair (aoSair). Manter consistência é a parte tricky - se você esquecer de reverter, boss permanece buffado.

class EstadoBossEmFuria extends EstadoIA {
  int ataqueOriginal = 0;
  int turnos = 0;

  @override String get nome => 'Furia';

  @override
  void aoEntrar(Inimigo inim) {
    ataqueOriginal = inim.ataque;
    inim.ataque *= 2;
    print('${inim.nome} ENTRA EM FURIA!');
  }

  @override
  void aoSair(Inimigo inim) {
    inim.ataque = ataqueOriginal;
  }

  @override
  void agir(Inimigo inim, EstadoJogo e) {
    var j = e.jogador;
    // ataque normal
    if (_adjacente(inim, j)) j.sofrerDano(inim.ataque);

    // 50% chance de ataque duplo
    if (Random().nextDouble() < 0.5 && _adjacente(inim, j)) {
      print('${inim.nome} desfere ataque duplo!');
      j.sofrerDano(inim.ataque);
    }
    turnos++;
  }

  bool _adjacente(Inimigo a, Pos b) =>
      (a.x - b.x).abs() <= 1 && (a.y - b.y).abs() <= 1;

  @override
  EstadoIA? proximoEstado(Inimigo inim, EstadoJogo e) {
    return turnos >= 5 ? null : null;  // dura para sempre ate FSM externa trocar
  }
}

Salvar ataqueOriginal em aoEntrar é a melhor prática - permite reverter exatamente. Variação: efeito visual constante (boss ”@” piscando vermelho cada turno). Outra: gradiente - boss recupera HP devagar em fúria, mas perde alguns turnos depois.

Boss Final 36.10 - Substituir if/else por FSM

Volte ao Capítulo 34 (Strategy) e substitua os if/else aninhados que controlam a IA dos inimigos por uma FSM.

Solução de referência. Refatorar Strategy → FSM é o salto qualitativo da IA. Cada estado encapsula uma “fase” da personalidade; transições explícitas substituem if espalhados. Mais código, mas mais legível e testável.

class MaquinaEstadoIA {
  EstadoIA atual;
  Inimigo dono;

  MaquinaEstadoIA({required this.atual, required this.dono}) {
    atual.aoEntrar(dono);
  }

  void tick(EstadoJogo e) {
    atual.agir(dono, e);
    var proximo = atual.proximoEstado(dono, e);
    if (proximo != null) {
      atual.aoSair(dono);
      atual = proximo;
      atual.aoEntrar(dono);
    }
  }
}

class InimigoComFSM extends Inimigo {
  late MaquinaEstadoIA fsm;

  InimigoComFSM({required EstadoIA estadoInicial, required super.nome, required super.hp, required super.maxHp, required super.ataque, required super.simbolo, required super.descricao}) {
    fsm = MaquinaEstadoIA(atual: estadoInicial, dono: this);
  }

  void agir(EstadoJogo e) {
    fsm.tick(e);
  }
}

// uso:
void main() {
  var orc = InimigoComFSM(
    estadoInicial: EstadoVigiaEspecial(),
    nome: 'Orc Vigia',
    hp: 12, maxHp: 12, ataque: 5, simbolo: 'O',
    descricao: 'Vigia o corredor.',
  );

  var estado = EstadoJogo(Jogador('Aldric'), [MapaMasmorra(20, 10)], 0);
  for (var turno = 1; turno <= 10; turno++) {
    print('Turno $turno (estado: ${orc.fsm.atual.nome})');
    orc.agir(estado);
  }
}

Variação: visualizar transições como grafo - desenhar diagrama digraph G { Vigia -> Confuso; Confuso -> Lucido; } em DOT (Graphviz) para documentar a FSM. Outra: testes unitários por estado - “dado estado Confuso, após 3 turnos, transiciona para Lúcido”.


Capítulo 37 - Síntese: O Jogo Completo, Polido e Pronto

Desafio 37.1 - Tela de Game Over

Crie uma tela de “Game Over” que mostra estatísticas do jogo (total de mortes infligidas, ouro coletado, andar máximo atingido).

Solução de referência. Tela final é momento dramático - merece atenção ao detalhe. Combinar o telaGameOver do cap. 6 com as estatísticas agregadas (cap. 16 + cap. 26) entrega o melhor: arte + dados. Pausa antes de aceitar input para o jogador absorver.

class EstatisticasFinais {
  final String nomeJogador;
  final int turnos;
  final int inimigosDerrotados;
  final int ouroAcumulado;
  final int andarMaximo;
  final String causaMorte;

  EstatisticasFinais({
    required this.nomeJogador,
    required this.turnos,
    required this.inimigosDerrotados,
    required this.ouroAcumulado,
    required this.andarMaximo,
    required this.causaMorte,
  });
}

String renderizarGameOver(EstatisticasFinais s) {
  var sb = StringBuffer();
  var w = 50;
  sb.writeln('╔${"═" * (w - 2)}╗');
  sb.writeln('║${"GAME OVER".padLeft((w + 7) ~/ 2).padRight(w - 2)}║');
  sb.writeln('╠${"═" * (w - 2)}╣');

  // caveira ASCII
  const caveira = [
    '     _____',
    '    /     \\',
    '   |  X X  |',
    '   |   ^   |',
    '   |  \_/  |',
    '    \_____/',
  ];
  for (var l in caveira) sb.writeln('║ ${l.padRight(w - 3)}║');

  sb.writeln('║${"".padRight(w - 2)}║');
  sb.writeln('║ ${s.nomeJogador.padRight(w - 3)}║');
  sb.writeln('║${"".padRight(w - 2)}║');
  sb.writeln('║ Turnos: ${s.turnos}'.padRight(w - 1) + '║');
  sb.writeln('║ Mortes: ${s.inimigosDerrotados}'.padRight(w - 1) + '║');
  sb.writeln('║ Ouro: ${s.ouroAcumulado}'.padRight(w - 1) + '║');
  sb.writeln('║ Andar maximo: ${s.andarMaximo}'.padRight(w - 1) + '║');
  sb.writeln('║${"".padRight(w - 2)}║');
  sb.writeln('║ Causa: ${s.causaMorte}'.padRight(w - 1) + '║');
  sb.writeln('╚${"═" * (w - 2)}╝');
  return sb.toString();
}

Variação: animação progressiva (cada linha aparece com sleep 100ms) - sensação de “epitáfio sendo gravado”. Outra: comparar com recordes pessoais (Novo recorde! em verde se andar > recorde anterior).

Desafio 37.2 - Sistema de Conquistas

Implemente um sistema de “Conquistas” que desbloqueiam marcos (primeira morte, 10 mortes, 100 mortes, derrotar boss, etc).

Solução de referência. Conquistas são metas observáveis: subsistema escuta eventos e marca como desbloqueada quando condição é atingida. Persistir entre runs torna meta-progressão real.

class Conquista {
  final String id;
  final String titulo;
  final String descricao;
  final bool Function(EstatisticasGlobais) condicao;
  bool desbloqueada;

  Conquista({
    required this.id,
    required this.titulo,
    required this.descricao,
    required this.condicao,
    this.desbloqueada = false,
  });
}

class EstatisticasGlobais {
  int inimigosDerrotados = 0;
  int bossesDerrotados = 0;
  int totalAndares = 0;
  int totalOuro = 0;
}

class SistemaConquistas {
  final List<Conquista> conquistas = [
    Conquista(id: 'primeira_morte', titulo: 'Primeira Lamina',
              descricao: 'Derrotou seu primeiro inimigo.',
              condicao: (s) => s.inimigosDerrotados >= 1),
    Conquista(id: 'dez_mortes', titulo: 'Caca-Recompensas',
              descricao: 'Derrotou 10 inimigos.',
              condicao: (s) => s.inimigosDerrotados >= 10),
    Conquista(id: 'cem_mortes', titulo: 'Carniceiro',
              descricao: 'Derrotou 100 inimigos.',
              condicao: (s) => s.inimigosDerrotados >= 100),
    Conquista(id: 'primeiro_boss', titulo: 'Caca-Dragoes',
              descricao: 'Derrotou seu primeiro chefao.',
              condicao: (s) => s.bossesDerrotados >= 1),
    Conquista(id: 'profundo', titulo: 'Profundeza',
              descricao: 'Chegou ao andar 20.',
              condicao: (s) => s.totalAndares >= 20),
  ];

  void avaliar(EstatisticasGlobais s) {
    for (var c in conquistas) {
      if (!c.desbloqueada && c.condicao(s)) {
        c.desbloqueada = true;
        _exibir(c);
      }
    }
  }

  void _exibir(Conquista c) {
    print('');
    print('  ╔══════════════════════════╗');
    print('  ║   CONQUISTA DESBLOQUEADA ║');
    print('  ║ ${c.titulo.padRight(24)} ║');
    print('  ║ ${c.descricao.padRight(24)} ║');
    print('  ╚══════════════════════════╝');
  }
}

Para persistência, serializar desbloqueada em JSON; cada início de run carrega. Variação: conquistas secretas (nome ??? até desbloqueio); tier dourada vs prata vs bronze; conquistas com recompensa (pontosBonus).

Desafio 37.3 - Modo Desafio

Adicione um “Modo Desafio” onde o jogo é mais difícil (inimigos mais fortes, menos poções encontradas).

Solução de referência. Modo desafio é multiplicadores aplicados na geração + restrições adicionais. Adicione enum ModoJogo e use-o como filtro nas funções de geração/spawn.

enum ModoJogo {
  classico(multHp: 1.0, multAtaque: 1.0, multDrops: 1.0),
  desafio(multHp: 1.5, multAtaque: 1.3, multDrops: 0.5),
  hardcore(multHp: 2.0, multAtaque: 1.5, multDrops: 0.2);

  final double multHp, multAtaque, multDrops;
  const ModoJogo({required this.multHp, required this.multAtaque, required this.multDrops});
}

class GeradorComModo {
  final ModoJogo modo;
  final Random rng;
  GeradorComModo(this.modo, this.rng);

  Inimigo gerarInimigo() {
    var base = Orc();
    base.hp = (base.hp * modo.multHp).round();
    base.maxHp = base.hp;
    base.ataque = (base.ataque * modo.multAtaque).round();
    return base;
  }

  Item? talvezDropar() {
    if (rng.nextDouble() > modo.multDrops) return null;
    return Pocao(nome: 'Pocao', descricao: '', peso: 100, curaHP: 10);
  }
}

Modo “hardcore” com multDrops 0.2 reduz drops em 80% - sobrevivência fica brutal. Variação: regras qualitativas no modo (não só números): “Hardcore: morte permanente, sem load do save”. Outra: modificadores semanais (cada semana traz set diferente: “Esta semana: inimigos +30% velocidade”).

Desafio 37.4 - Enciclopédia de Inimigos

Crie uma “Enciclopédia de Inimigos” acessível no menu que mostra cada tipo encontrado, com estatísticas e descrição.

Solução de referência. Enciclopédia (bestiário) recompensa exploração - cada novo inimigo descoberto é registrado. Lista exibe nome, símbolo, descrição, contagem de derrotados. Estilo “Pokédex”.

class EntradaBestiario {
  final String nome;
  final String simbolo;
  final String descricao;
  bool descoberto = false;
  int derrotados = 0;
  int hpReferencia;

  EntradaBestiario({
    required this.nome,
    required this.simbolo,
    required this.descricao,
    required this.hpReferencia,
  });
}

class Bestiario {
  final Map<String, EntradaBestiario> entradas = {};

  void registrarEncontro(Inimigo i) {
    var entrada = entradas[i.runtimeType.toString()] ??= EntradaBestiario(
      nome: i.nome, simbolo: i.simbolo, descricao: i.descricao,
      hpReferencia: i.maxHp,
    );
    entrada.descoberto = true;
  }

  void registrarMorte(Inimigo i) {
    entradas[i.runtimeType.toString()]?.derrotados++;
  }

  String renderizar() {
    var sb = StringBuffer();
    sb.writeln('=== BESTIARIO ===');
    var descobertos = entradas.values.where((e) => e.descoberto);
    for (var e in descobertos) {
      sb.writeln('[${e.simbolo}] ${e.nome}');
      sb.writeln('    ${e.descricao}');
      sb.writeln('    HP: ${e.hpReferencia}, Derrotados: ${e.derrotados}');
      sb.writeln('');
    }
    // entradas nao descobertas mostradas como ???
    var ocultas = entradas.values.where((e) => !e.descoberto).length;
    if (ocultas > 0) sb.writeln('+ $ocultas criaturas desconhecidas...');
    return sb.toString();
  }
}

Mostrar “+N desconhecidas” cria curiosidade: jogador sabe que tem mais para encontrar. Variação: entrada da Pokédex evolui - primeira vez “Visto”, após 5 mortes “Estudado”, após 20 “Mestre” - cada nível desbloqueia mais info.

Boss Final 37.5 - Refatoração MVC Completa

Refatore todo o jogo para usar um padrão MVC (Model-View-Controller) limpo. O Model é EstadoJogo e lógica; View é renderização; Controller é input + orquestração.

Solução de referência. MVC em CLI separa três camadas. Model: o EstadoJogo e classes de domínio (Jogador, Mapa, Inimigo) - dados e regras. View: classes que apenas renderizam (Renderizador, HudLateral, LogJogo.renderizar). Controller: o game loop que processa input e chama Model + View.

// === MODEL (lib/src/model/) ===
class EstadoJogo {
  Jogador jogador;
  List<MapaMasmorra> andares;
  int andarAtual;
  // dados puros, sem render nem input
  EstadoJogo(this.jogador, this.andares, this.andarAtual);
}

class RegraCombate {
  static int calcularDano(Jogador j, Inimigo i, Random rng) {
    var base = j.ataque;
    var critico = rng.nextDouble() < 0.15;
    return critico ? base * 2 : base;
  }
}

// === VIEW (lib/src/view/) ===
abstract class View {
  void desenhar(EstadoJogo e);
}

class TerminalView extends View {
  @override
  void desenhar(EstadoJogo e) {
    // clear screen + renderizar mapa + HUD + log
    print('\u001B[2J\u001B[H');
    var mapa = e.andares[e.andarAtual];
    print(renderizarMapa(mapa, e.jogador));
    print(hudCompleto(e.jogador, e.andarAtual + 1));
  }
}

// === CONTROLLER (lib/src/controller/) ===
class GameController {
  final EstadoJogo modelo;
  final View view;
  final InputReader entrada;

  GameController(this.modelo, this.view, this.entrada);

  void executar() {
    while (modelo.jogador.hp > 0) {
      view.desenhar(modelo);
      var cmd = entrada.lerComando();
      _processar(cmd);
    }
  }

  void _processar(Comando cmd) {
    switch (cmd) {
      case ComandoMover(:final direcao):
        _mover(direcao);
      case ComandoAtacar():
        _atacar();
      default:
        // ...
    }
  }

  void _mover(Direcao d) {
    // chama regras do Model, atualiza estado
  }

  void _atacar() {
    // chama RegraCombate, atualiza estado
  }
}

// === MAIN ===
void main() {
  var jogador = Jogador('Aldric');
  var mapa = MapaMasmorra(20, 10);
  var estado = EstadoJogo(jogador, [mapa], 0);

  var controller = GameController(
    estado,
    TerminalView(),
    InputReader(),
  );
  controller.executar();
}

A separação rende benefícios concretos: testar RegraCombate (regra pura) é trivial. Trocar TerminalView por WebView (mesmo Model) suporta múltiplas plataformas. Substituir InputReader por RoboInput permite testes automatizados. Cada peça evolui sozinha sem arrastar as outras. É o padrão que sustenta jogos comerciais durante anos de desenvolvimento - e o que prepara você para Flutter, React, Vue, ou qualquer arquitetura moderna baseada em estado.

Variação: separar ainda mais com Repository (acesso a save), Service (operações complexas), Adapter (CLI vs GUI) - arquitetura hexagonal. Para o livro, MVC é suficiente; para apps de produção, dependa do tamanho.


Onde contribuir com gabaritos

Tem uma solução elegante para algum desafio? Envie pelo formulário em /beta-readers marcando como “Sugestão de melhoria” e citando o número (Desafio 14.3, por exemplo). Soluções selecionadas entram aqui com crédito nominal no colofão.

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