Mini Projeto: Quiz Interativo

[97] Mini Projeto: Quiz Interativo

O fecho do Módulo 2 junta tudo num quiz de dez perguntas: timer de 20 segundos por questão, pontuação que premia a resposta rápida, feedback com explicação e os cinco melhores placares guardados no navegador. Antes dele, uma revisão curta de DOM, eventos, formulários, storage e temporizadores.
Javascript

24 min de leitura

Chegamos ao fim do Módulo 2. Em sete artigos você aprendeu a manipular o DOM, reagir a eventos, criar e remover elementos, trabalhar com formulários, persistir dados com LocalStorage e controlar o tempo com temporizadores.

Agora vamos unir tudo isso em um único projeto: um Quiz Interativo completo — com perguntas cronometradas, pontuação, histórico de partidas, feedback visual e persistência de recordes.

Primeiro, a revisão rápida. Depois, o projeto.

Revisão rápida — Módulo 2

DOM — seleção e manipulação

// Selecionando
const titulo = document.querySelector("#titulo");
const itens = document.querySelectorAll(".item");

// Conteúdo
titulo.textContent = "Novo título";
titulo.innerHTML = "Título com <strong>negrito</strong>";

// Classes
titulo.classList.add("destaque");
titulo.classList.toggle("oculto");
titulo.classList.contains("ativo"); // true/false

// Atributos
link.setAttribute("href", "https://...");
link.getAttribute("href");

Criando e removendo elementos

const li = document.createElement("li");
li.textContent = "Novo item";
li.classList.add("item");
lista.appendChild(li);

// Moderno
lista.append(li);
lista.prepend(li);
li.remove();

// Performance com muitos elementos
const fragment = document.createDocumentFragment();
dados.forEach(d => {
  const el = document.createElement("div");
  el.textContent = d;
  fragment.appendChild(el);
});
container.appendChild(fragment);

Eventos

botao.addEventListener("click", (e) => {
  e.preventDefault();
  console.log(e.target);
});

// Delegação
lista.addEventListener("click", (e) => {
  if (e.target.matches("li")) {
    e.target.classList.toggle("selecionado");
  }
});

// Remover
function handler() { console.log("clicou"); }
botao.addEventListener("click", handler);
botao.removeEventListener("click", handler);

Formulários

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const dados = Object.fromEntries(new FormData(form).entries());
  console.log(dados);
});

campo.addEventListener("blur", () => validarCampo());
campo.addEventListener("input", () => {
  if (campo.classList.contains("invalido")) validarCampo();
});

LocalStorage

const storage = {
  get: (chave, padrao = null) => {
    try {
      const item = localStorage.getItem(chave);
      return item !== null ? JSON.parse(item) : padrao;
    } catch { return padrao; }
  },
  set: (chave, valor) => {
    try {
      localStorage.setItem(chave, JSON.stringify(valor));
    } catch (e) { console.error(e); }
  },
};

Temporizadores

// Uma vez
const id = setTimeout(() => acao(), 2000);
clearTimeout(id);

// Repetidamente
const idInt = setInterval(() => acao(), 1000);
clearInterval(idInt);

// Debounce
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

Mini Projeto — Quiz Interativo

O quiz terá:

  • 10 perguntas sobre JavaScript
  • 20 segundos por pergunta com barra de tempo
  • Feedback imediato (certo/errado + explicação)
  • Pontuação baseada em velocidade de resposta
  • Histórico dos últimos 5 recordes salvo no LocalStorage
  • Tela de resultado com estatísticas detalhadas
  • Animações e transições suaves

As perguntas

const perguntas = [
  {
    id: 1,
    texto: "Qual palavra-chave declara uma variável que não pode ser reatribuída?",
    opcoes: ["var", "let", "const", "static"],
    correta: 2,
    explicacao: "const declara uma variável cujo vínculo não pode ser reatribuído após a declaração.",
  },
  {
    id: 2,
    texto: "Qual método adiciona um elemento ao FINAL de um array?",
    opcoes: ["shift()", "unshift()", "pop()", "push()"],
    correta: 3,
    explicacao: "push() adiciona um ou mais elementos ao final do array e retorna o novo comprimento.",
  },
  {
    id: 3,
    texto: "O que o operador === verifica?",
    opcoes: [
      "Apenas o valor",
      "Apenas o tipo",
      "Valor e tipo",
      "Referência na memória",
    ],
    correta: 2,
    explicacao: "=== (igualdade estrita) compara valor E tipo, sem conversão automática.",
  },
  {
    id: 4,
    texto: "Qual método do array retorna um NOVO array com elementos transformados?",
    opcoes: ["forEach()", "filter()", "map()", "reduce()"],
    correta: 2,
    explicacao: "map() cria um novo array com os resultados de chamar a função em cada elemento.",
  },
  {
    id: 5,
    texto: "O que é uma closure em JavaScript?",
    opcoes: [
      "Uma função sem parâmetros",
      "Uma função que lembra o escopo onde foi criada",
      "Um bloco try/catch",
      "Um método de array",
    ],
    correta: 1,
    explicacao: "Closure é quando uma função retém acesso às variáveis do escopo onde foi definida.",
  },
  {
    id: 6,
    texto: "Qual evento do formulário deve sempre ter e.preventDefault()?",
    opcoes: ["click", "input", "submit", "blur"],
    correta: 2,
    explicacao: "submit recarrega a página por padrão. preventDefault() evita esse comportamento.",
  },
  {
    id: 7,
    texto: "Como converter um objeto para string JSON?",
    opcoes: [
      "JSON.parse(obj)",
      "JSON.stringify(obj)",
      "obj.toString()",
      "String(obj)",
    ],
    correta: 1,
    explicacao: "JSON.stringify() converte um valor JavaScript em string JSON.",
  },
  {
    id: 8,
    texto: "Qual a diferença entre LocalStorage e SessionStorage?",
    opcoes: [
      "LocalStorage é mais rápido",
      "SessionStorage aceita objetos diretamente",
      "LocalStorage persiste após fechar o navegador",
      "Não há diferença",
    ],
    correta: 2,
    explicacao: "LocalStorage persiste indefinidamente. SessionStorage é limpo ao fechar a aba.",
  },
  {
    id: 9,
    texto: "O que o método querySelector retorna se não encontrar o elemento?",
    opcoes: ["undefined", "false", "null", "0"],
    correta: 2,
    explicacao: "querySelector retorna null quando nenhum elemento corresponde ao seletor.",
  },
  {
    id: 10,
    texto: "Qual padrão cancela e reagenda um setTimeout a cada evento?",
    opcoes: ["Throttle", "Debounce", "Polling", "Memoize"],
    correta: 1,
    explicacao: "Debounce cancela o timer anterior e agenda um novo, executando só após inatividade.",
  },
];

O HTML

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <title>Quiz JavaScript</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    :root {
      --bg: #0f1117;
      --surface: #1a1d27;
      --surface2: #232637;
      --border: #2e3248;
      --accent: #7c6ff7;
      --green: #4ade80;
      --red: #f87171;
      --yellow: #fbbf24;
      --text: #e4e6f0;
      --muted: #8b8fa8;
    }

    body {
      font-family: 'Segoe UI', sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 1rem;
    }

    .tela {
      display: none;
      width: 100%;
      max-width: 640px;
      animation: aparecer .3s ease;
    }

    .tela.ativa { display: block; }

    @keyframes aparecer {
      from { opacity: 0; transform: translateY(10px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    /* ── Tela Inicial ── */
    .inicio {
      background: var(--surface);
      border-radius: 20px;
      padding: 3rem 2rem;
      text-align: center;
      border: 1px solid var(--border);
    }

    .emoji { font-size: 4rem; margin-bottom: 1rem; }

    .inicio h1 {
      font-size: 2rem;
      margin-bottom: .5rem;
    }

    .inicio p {
      color: var(--muted);
      margin-bottom: 2rem;
      line-height: 1.6;
    }

    .recordes {
      background: var(--surface2);
      border-radius: 12px;
      padding: 1.25rem;
      margin-bottom: 2rem;
      text-align: left;
    }

    .recordes h3 {
      font-size: .85rem;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: .1em;
      margin-bottom: 1rem;
    }

    .recorde-item {
      display: flex;
      justify-content: space-between;
      padding: .4rem 0;
      border-bottom: 1px solid var(--border);
      font-size: .9rem;
    }

    .recorde-item:last-child { border: none; }
    .recorde-pontos { color: var(--accent); font-weight: 700; }

    /* ── Botões ── */
    .btn {
      display: inline-block;
      padding: .85rem 2rem;
      border: none;
      border-radius: 10px;
      font-size: 1rem;
      font-weight: 700;
      cursor: pointer;
      transition: all .2s;
    }

    .btn-primary {
      background: var(--accent);
      color: white;
      width: 100%;
    }

    .btn-primary:hover { background: #6c5fe0; transform: translateY(-1px); }

    .btn-secondary {
      background: var(--surface2);
      color: var(--text);
      border: 1px solid var(--border);
    }

    .btn-secondary:hover { background: var(--border); }

    /* ── Tela do Quiz ── */
    .quiz-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1.5rem;
    }

    .progresso-texto { color: var(--muted); font-size: .9rem; }
    .pontuacao-atual { color: var(--accent); font-weight: 700; }

    .barra-progresso {
      height: 4px;
      background: var(--surface2);
      border-radius: 999px;
      margin-bottom: 1.5rem;
      overflow: hidden;
    }

    .barra-progresso-fill {
      height: 100%;
      background: var(--accent);
      border-radius: 999px;
      transition: width .4s ease;
    }

    /* Timer */
    .timer-container {
      display: flex;
      align-items: center;
      gap: 1rem;
      margin-bottom: 1.5rem;
    }

    .timer-numero {
      font-size: 1.5rem;
      font-weight: 700;
      font-variant-numeric: tabular-nums;
      min-width: 2rem;
      text-align: center;
      transition: color .3s;
    }

    .timer-numero.urgente { color: var(--red); }

    .barra-tempo {
      flex: 1;
      height: 8px;
      background: var(--surface2);
      border-radius: 999px;
      overflow: hidden;
    }

    .barra-tempo-fill {
      height: 100%;
      background: var(--accent);
      border-radius: 999px;
      transition: width .1s linear, background .3s;
    }

    .barra-tempo-fill.urgente { background: var(--red); }

    /* Pergunta */
    .card-pergunta {
      background: var(--surface);
      border-radius: 16px;
      padding: 2rem;
      margin-bottom: 1rem;
      border: 1px solid var(--border);
    }

    .numero-pergunta {
      font-size: .8rem;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: .1em;
      margin-bottom: .75rem;
    }

    .texto-pergunta {
      font-size: 1.15rem;
      line-height: 1.6;
      font-weight: 500;
    }

    /* Opções */
    .opcoes {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: .75rem;
      margin-bottom: 1rem;
    }

    .opcao {
      background: var(--surface);
      border: 2px solid var(--border);
      border-radius: 12px;
      padding: 1rem;
      cursor: pointer;
      transition: all .2s;
      font-size: .95rem;
      color: var(--text);
      text-align: left;
      line-height: 1.4;
    }

    .opcao:hover:not(:disabled) {
      border-color: var(--accent);
      background: var(--surface2);
      transform: translateY(-1px);
    }

    .opcao:disabled { cursor: not-allowed; }

    .opcao.correta {
      border-color: var(--green);
      background: rgba(74, 222, 128, .1);
      color: var(--green);
    }

    .opcao.errada {
      border-color: var(--red);
      background: rgba(248, 113, 113, .1);
      color: var(--red);
    }

    .opcao.neutra {
      opacity: .4;
    }

    /* Explicação */
    .explicacao {
      background: var(--surface2);
      border-radius: 12px;
      padding: 1rem 1.25rem;
      font-size: .9rem;
      color: var(--muted);
      margin-bottom: 1rem;
      border-left: 3px solid var(--accent);
      display: none;
      animation: aparecer .3s ease;
    }

    .explicacao.visivel { display: block; }

    .btn-proxima {
      width: 100%;
      margin-top: .5rem;
      display: none;
    }

    .btn-proxima.visivel { display: block; }

    /* ── Tela de Resultado ── */
    .resultado {
      background: var(--surface);
      border-radius: 20px;
      padding: 2.5rem 2rem;
      text-align: center;
      border: 1px solid var(--border);
    }

    .resultado-emoji { font-size: 4rem; margin-bottom: 1rem; }
    .resultado h2 { font-size: 1.75rem; margin-bottom: .5rem; }
    .resultado .subtitulo { color: var(--muted); margin-bottom: 2rem; }

    .stats {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 1rem;
      margin-bottom: 2rem;
    }

    .stat {
      background: var(--surface2);
      border-radius: 12px;
      padding: 1.25rem .75rem;
    }

    .stat-valor {
      font-size: 1.75rem;
      font-weight: 700;
      color: var(--accent);
      display: block;
    }

    .stat-label {
      font-size: .8rem;
      color: var(--muted);
      margin-top: .25rem;
      display: block;
    }

    .botoes-resultado {
      display: flex;
      gap: 1rem;
    }

    .botoes-resultado .btn { flex: 1; }

    @media (max-width: 480px) {
      .opcoes { grid-template-columns: 1fr; }
      .stats { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>

  <!-- Tela Inicial -->
  <div class="tela ativa" id="tela-inicio">
    <div class="inicio">
      <div class="emoji">🧠</div>
      <h1>Quiz JavaScript</h1>
      <p>10 perguntas · 20 segundos cada · Quanto mais rápido, mais pontos!</p>

      <div class="recordes" id="recordes">
        <h3>🏆 Melhores pontuações</h3>
        <div id="lista-recordes"></div>
      </div>

      <button class="btn btn-primary" id="btn-iniciar">Começar Quiz</button>
    </div>
  </div>

  <!-- Tela do Quiz -->
  <div class="tela" id="tela-quiz">
    <div class="quiz-header">
      <span class="progresso-texto" id="progresso-texto">Pergunta 1 de 10</span>
      <span class="pontuacao-atual" id="pontuacao-atual">0 pts</span>
    </div>

    <div class="barra-progresso">
      <div class="barra-progresso-fill" id="barra-progresso-fill"></div>
    </div>

    <div class="timer-container">
      <span class="timer-numero" id="timer-numero">20</span>
      <div class="barra-tempo">
        <div class="barra-tempo-fill" id="barra-tempo-fill"></div>
      </div>
    </div>

    <div class="card-pergunta">
      <div class="numero-pergunta" id="numero-pergunta"></div>
      <div class="texto-pergunta" id="texto-pergunta"></div>
    </div>

    <div class="opcoes" id="opcoes"></div>

    <div class="explicacao" id="explicacao"></div>

    <button class="btn btn-primary btn-proxima" id="btn-proxima">
      Próxima pergunta →
    </button>
  </div>

  <!-- Tela de Resultado -->
  <div class="tela" id="tela-resultado">
    <div class="resultado">
      <div class="resultado-emoji" id="resultado-emoji"></div>
      <h2 id="resultado-titulo"></h2>
      <p class="subtitulo" id="resultado-subtitulo"></p>

      <div class="stats">
        <div class="stat">
          <span class="stat-valor" id="stat-pontos"></span>
          <span class="stat-label">Pontuação</span>
        </div>
        <div class="stat">
          <span class="stat-valor" id="stat-acertos"></span>
          <span class="stat-label">Acertos</span>
        </div>
        <div class="stat">
          <span class="stat-valor" id="stat-tempo"></span>
          <span class="stat-label">Tempo médio</span>
        </div>
      </div>

      <div class="botoes-resultado">
        <button class="btn btn-secondary" id="btn-menu">Menu</button>
        <button class="btn btn-primary" id="btn-jogar-novamente">Jogar novamente</button>
      </div>
    </div>
  </div>

<script>
// ──────────────────────────────────────────────────────
// DADOS
// ──────────────────────────────────────────────────────

const perguntas = [
  {
    id: 1,
    texto: "Qual palavra-chave declara uma variável que não pode ser reatribuída?",
    opcoes: ["var", "let", "const", "static"],
    correta: 2,
    explicacao: "const declara uma variável cujo vínculo não pode ser reatribuído após a declaração.",
  },
  {
    id: 2,
    texto: "Qual método adiciona um elemento ao FINAL de um array?",
    opcoes: ["shift()", "unshift()", "pop()", "push()"],
    correta: 3,
    explicacao: "push() adiciona um ou mais elementos ao final do array e retorna o novo comprimento.",
  },
  {
    id: 3,
    texto: "O que o operador === verifica?",
    opcoes: ["Apenas o valor", "Apenas o tipo", "Valor e tipo", "Referência na memória"],
    correta: 2,
    explicacao: "=== (igualdade estrita) compara valor E tipo, sem conversão automática.",
  },
  {
    id: 4,
    texto: "Qual método do array retorna um NOVO array com elementos transformados?",
    opcoes: ["forEach()", "filter()", "map()", "reduce()"],
    correta: 2,
    explicacao: "map() cria um novo array com os resultados de chamar a função em cada elemento.",
  },
  {
    id: 5,
    texto: "O que é uma closure em JavaScript?",
    opcoes: [
      "Uma função sem parâmetros",
      "Uma função que lembra o escopo onde foi criada",
      "Um bloco try/catch",
      "Um método de array",
    ],
    correta: 1,
    explicacao: "Closure é quando uma função retém acesso às variáveis do escopo onde foi definida.",
  },
  {
    id: 6,
    texto: "Qual evento do formulário deve sempre ter e.preventDefault()?",
    opcoes: ["click", "input", "submit", "blur"],
    correta: 2,
    explicacao: "submit recarrega a página por padrão. preventDefault() evita esse comportamento.",
  },
  {
    id: 7,
    texto: "Como converter um objeto para string JSON?",
    opcoes: ["JSON.parse(obj)", "JSON.stringify(obj)", "obj.toString()", "String(obj)"],
    correta: 1,
    explicacao: "JSON.stringify() converte um valor JavaScript em string JSON.",
  },
  {
    id: 8,
    texto: "Qual a diferença entre LocalStorage e SessionStorage?",
    opcoes: [
      "LocalStorage é mais rápido",
      "SessionStorage aceita objetos diretamente",
      "LocalStorage persiste após fechar o navegador",
      "Não há diferença",
    ],
    correta: 2,
    explicacao: "LocalStorage persiste indefinidamente. SessionStorage é limpo ao fechar a aba.",
  },
  {
    id: 9,
    texto: "O que querySelector retorna se não encontrar o elemento?",
    opcoes: ["undefined", "false", "null", "0"],
    correta: 2,
    explicacao: "querySelector retorna null quando nenhum elemento corresponde ao seletor.",
  },
  {
    id: 10,
    texto: "Qual padrão cancela e reagenda um setTimeout a cada evento?",
    opcoes: ["Throttle", "Debounce", "Polling", "Memoize"],
    correta: 1,
    explicacao: "Debounce cancela o timer anterior e agenda um novo, executando só após inatividade.",
  },
];

// ──────────────────────────────────────────────────────
// STORAGE
// ──────────────────────────────────────────────────────

const storage = {
  get: (chave, padrao = null) => {
    try {
      const item = localStorage.getItem(chave);
      return item !== null ? JSON.parse(item) : padrao;
    } catch { return padrao; }
  },
  set: (chave, valor) => {
    try { localStorage.setItem(chave, JSON.stringify(valor)); }
    catch (e) { console.error(e); }
  },
};

// ──────────────────────────────────────────────────────
// ESTADO DO JOGO
// ──────────────────────────────────────────────────────

let estado = {
  perguntaAtual: 0,
  pontuacao: 0,
  acertos: 0,
  temposResposta: [],
  inicioPergunta: null,
  timerIntervalo: null,
  tempoRestante: 20,
  respondeu: false,
  perguntasEmbaralhadas: [],
};

// ──────────────────────────────────────────────────────
// REFERÊNCIAS
// ──────────────────────────────────────────────────────

const telas = {
  inicio:     document.querySelector("#tela-inicio"),
  quiz:       document.querySelector("#tela-quiz"),
  resultado:  document.querySelector("#tela-resultado"),
};

const el = {
  listaRecordes:      document.querySelector("#lista-recordes"),
  progressoTexto:     document.querySelector("#progresso-texto"),
  pontuacaoAtual:     document.querySelector("#pontuacao-atual"),
  barraProgressoFill: document.querySelector("#barra-progresso-fill"),
  timerNumero:        document.querySelector("#timer-numero"),
  barraTempoFill:     document.querySelector("#barra-tempo-fill"),
  numeroPergunta:     document.querySelector("#numero-pergunta"),
  textoPergunta:      document.querySelector("#texto-pergunta"),
  opcoes:             document.querySelector("#opcoes"),
  explicacao:         document.querySelector("#explicacao"),
  btnProxima:         document.querySelector("#btn-proxima"),
  resultadoEmoji:     document.querySelector("#resultado-emoji"),
  resultadoTitulo:    document.querySelector("#resultado-titulo"),
  resultadoSubtitulo: document.querySelector("#resultado-subtitulo"),
  statPontos:         document.querySelector("#stat-pontos"),
  statAcertos:        document.querySelector("#stat-acertos"),
  statTempo:          document.querySelector("#stat-tempo"),
};

// ──────────────────────────────────────────────────────
// UTILITÁRIOS
// ──────────────────────────────────────────────────────

function mostrarTela(nome) {
  Object.values(telas).forEach(t => t.classList.remove("ativa"));
  telas[nome].classList.add("ativa");
}

function embaralhar(array) {
  // sort(() => Math.random() - 0.5) é o embaralhamento que todo mundo
  // escreve e que não funciona: a função de comparação precisa ser
  // consistente, e essa não é — o resultado fica enviesado, com
  // algumas posições muito mais prováveis que outras.
  const copia = [...array];
  for (let i = copia.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [copia[i], copia[j]] = [copia[j], copia[i]];
  }
  return copia; // Fisher-Yates: toda ordem com a mesma chance
}

function calcularPontos(tempoRestante, acertou) {
  if (!acertou) return 0;
  // Pontuação base + bônus de velocidade
  return 100 + Math.floor(tempoRestante * 10);
}

// ──────────────────────────────────────────────────────
// RECORDES
// ──────────────────────────────────────────────────────

function carregarRecordes() {
  const recordes = storage.get("quiz-recordes", []);

  if (recordes.length === 0) {
    el.listaRecordes.innerHTML =
      '<p style="color: var(--muted); font-size: .9rem;">Nenhuma partida ainda. Seja o primeiro!</p>';
    return;
  }

  el.listaRecordes.innerHTML = "";
  recordes.forEach((r, i) => {
    const div = document.createElement("div");
    div.classList.add("recorde-item");

    const medalhas = ["🥇", "🥈", "🥉", "4°", "5°"];
    div.innerHTML = `
      <span>${medalhas[i] || `${i + 1}°`} &nbsp; ${r.acertos}/10 acertos · ${r.data}</span>
      <span class="recorde-pontos">${r.pontuacao} pts</span>
    `;
    el.listaRecordes.appendChild(div);
  });
}

function salvarRecorde(pontuacao, acertos) {
  const recordes = storage.get("quiz-recordes", []);

  recordes.push({
    pontuacao,
    acertos,
    data: new Date().toLocaleDateString("pt-BR"),
  });

  // Ordena por pontuação e mantém os 5 melhores
  recordes.sort((a, b) => b.pontuacao - a.pontuacao);
  storage.set("quiz-recordes", recordes.slice(0, 5));
}

// ──────────────────────────────────────────────────────
// TIMER
// ──────────────────────────────────────────────────────

function iniciarTimer() {
  estado.tempoRestante = 20;
  estado.inicioPergunta = Date.now();

  atualizarTimer();

  estado.timerIntervalo = setInterval(() => {
    // Descontar 0.1 a cada tique acumularia atraso, como vimos no artigo
    // anterior: o intervalo promete "não antes de 100ms", nunca "exatamente".
    // Aqui cada tique pergunta as horas de novo, ancorado no início.
    const decorrido = (Date.now() - estado.inicioPergunta) / 1000;
    estado.tempoRestante = Math.max(0, 20 - decorrido);

    atualizarTimer();

    if (estado.tempoRestante === 0) {
      clearInterval(estado.timerIntervalo);
      if (!estado.respondeu) timeoutPergunta();
    }
  }, 100);
}

function atualizarTimer() {
  const segundos = Math.ceil(estado.tempoRestante);
  const porcentagem = (estado.tempoRestante / 20) * 100;
  const urgente = estado.tempoRestante <= 5;

  el.timerNumero.textContent = segundos;
  el.timerNumero.classList.toggle("urgente", urgente);
  el.barraTempoFill.style.width = `${porcentagem}%`;
  el.barraTempoFill.classList.toggle("urgente", urgente);
}

function pararTimer() {
  clearInterval(estado.timerIntervalo);
}

// ──────────────────────────────────────────────────────
// QUIZ
// ──────────────────────────────────────────────────────

function iniciarQuiz() {
  estado = {
    ...estado,
    perguntaAtual: 0,
    pontuacao: 0,
    acertos: 0,
    temposResposta: [],
    respondeu: false,
    perguntasEmbaralhadas: embaralhar(perguntas),
  };

  mostrarTela("quiz");
  carregarPergunta();
}

function carregarPergunta() {
  const pergunta = estado.perguntasEmbaralhadas[estado.perguntaAtual];
  const total = estado.perguntasEmbaralhadas.length;
  const numero = estado.perguntaAtual + 1;

  // Header
  el.progressoTexto.textContent = `Pergunta ${numero} de ${total}`;
  el.pontuacaoAtual.textContent = `${estado.pontuacao} pts`;
  el.barraProgressoFill.style.width = `${((numero - 1) / total) * 100}%`;

  // Pergunta
  el.numeroPergunta.textContent = `Pergunta ${numero}`;
  el.textoPergunta.textContent = pergunta.texto;

  // Opções
  el.opcoes.innerHTML = "";
  pergunta.opcoes.forEach((opcao, index) => {
    const btn = document.createElement("button");
    btn.classList.add("opcao");
    btn.textContent = opcao;
    btn.addEventListener("click", () => responder(index));
    el.opcoes.appendChild(btn);
  });

  // Limpa feedback anterior
  el.explicacao.textContent = "";
  el.explicacao.classList.remove("visivel");
  el.btnProxima.classList.remove("visivel");
  estado.respondeu = false;

  iniciarTimer();
}

function responder(indiceEscolhido) {
  if (estado.respondeu) return;
  estado.respondeu = true;
  pararTimer();

  const pergunta = estado.perguntasEmbaralhadas[estado.perguntaAtual];
  const acertou = indiceEscolhido === pergunta.correta;
  const tempoGasto = 20 - estado.tempoRestante;

  estado.temposResposta.push(tempoGasto);

  if (acertou) {
    estado.acertos++;
    const pts = calcularPontos(estado.tempoRestante, true);
    estado.pontuacao += pts;
  }

  // Feedback visual nas opções
  const botoes = el.opcoes.querySelectorAll(".opcao");
  botoes.forEach((btn, i) => {
    btn.disabled = true;
    if (i === pergunta.correta) {
      btn.classList.add("correta");
    } else if (i === indiceEscolhido && !acertou) {
      btn.classList.add("errada");
    } else {
      btn.classList.add("neutra");
    }
  });

  // Explicação
  el.explicacao.textContent = `💡 ${pergunta.explicacao}`;
  el.explicacao.classList.add("visivel");

  // Botão próxima
  const ultima = estado.perguntaAtual === estado.perguntasEmbaralhadas.length - 1;
  el.btnProxima.textContent = ultima ? "Ver resultado →" : "Próxima pergunta →";
  el.btnProxima.classList.add("visivel");

  // Atualiza pontuação no header
  el.pontuacaoAtual.textContent = `${estado.pontuacao} pts`;
}

function timeoutPergunta() {
  if (estado.respondeu) return;
  estado.respondeu = true;

  const pergunta = estado.perguntasEmbaralhadas[estado.perguntaAtual];
  estado.temposResposta.push(20);

  const botoes = el.opcoes.querySelectorAll(".opcao");
  botoes.forEach((btn, i) => {
    btn.disabled = true;
    if (i === pergunta.correta) btn.classList.add("correta");
    else btn.classList.add("neutra");
  });

  el.explicacao.textContent = `⏰ Tempo esgotado! ${pergunta.explicacao}`;
  el.explicacao.classList.add("visivel");

  const ultima = estado.perguntaAtual === estado.perguntasEmbaralhadas.length - 1;
  el.btnProxima.textContent = ultima ? "Ver resultado →" : "Próxima pergunta →";
  el.btnProxima.classList.add("visivel");
}

function proximaPergunta() {
  estado.perguntaAtual++;

  if (estado.perguntaAtual >= estado.perguntasEmbaralhadas.length) {
    encerrarQuiz();
  } else {
    carregarPergunta();
  }
}

function encerrarQuiz() {
  pararTimer();
  salvarRecorde(estado.pontuacao, estado.acertos);

  const tempoMedio = estado.temposResposta.length > 0
    ? (estado.temposResposta.reduce((a, b) => a + b, 0) / estado.temposResposta.length).toFixed(1)
    : 0;

  const porcentagem = Math.round((estado.acertos / perguntas.length) * 100);

  // Emoji e título baseados no desempenho
  const resultados = [
    { min: 90, emoji: "🏆", titulo: "Incrível!", subtitulo: "Você é um mestre do JavaScript!" },
    { min: 70, emoji: "🎉", titulo: "Muito bom!", subtitulo: "Você domina bem o JavaScript!" },
    { min: 50, emoji: "👍", titulo: "Bom trabalho!", subtitulo: "Continue praticando!" },
    { min: 0,  emoji: "📚", titulo: "Continue estudando!", subtitulo: "Revise os artigos e tente novamente." },
  ];

  const resultado = resultados.find(r => porcentagem >= r.min);

  el.resultadoEmoji.textContent = resultado.emoji;
  el.resultadoTitulo.textContent = resultado.titulo;
  el.resultadoSubtitulo.textContent = resultado.subtitulo;
  el.statPontos.textContent = estado.pontuacao;
  el.statAcertos.textContent = `${estado.acertos}/10`;
  el.statTempo.textContent = `${tempoMedio}s`;

  mostrarTela("resultado");
  carregarRecordes();
}

// ──────────────────────────────────────────────────────
// EVENTOS
// ──────────────────────────────────────────────────────

document.querySelector("#btn-iniciar").addEventListener("click", iniciarQuiz);
document.querySelector("#btn-jogar-novamente").addEventListener("click", iniciarQuiz);
document.querySelector("#btn-menu").addEventListener("click", () => mostrarTela("inicio"));

el.btnProxima.addEventListener("click", proximaPergunta);

// Atalho de teclado: 1, 2, 3, 4 para responder
document.addEventListener("keydown", (e) => {
  if (telas.quiz.classList.contains("ativa") && !estado.respondeu) {
    const mapa = { "1": 0, "2": 1, "3": 2, "4": 3 };
    if (mapa[e.key] !== undefined) responder(mapa[e.key]);
  }

  if (e.key === "Enter" && el.btnProxima.classList.contains("visivel")) {
    proximaPergunta();
  }
});

// ──────────────────────────────────────────────────────
// INICIALIZAÇÃO
// ──────────────────────────────────────────────────────

carregarRecordes();
</script>
</body>
</html>

O que este projeto exercitou

Conceito do Módulo 2 Onde foi usado
Seleção de elementos Todas as referências do quiz
classList e toggle Feedback visual das opções e timer
createElement e append Botões de opção gerados dinamicamente
innerHTML e appendChild Renderização da lista de recordes
Eventos de clique Opções, botões e teclas
Listener no document Atalhos de teclado globais — ouvir no documento não é delegação: não há filho sendo identificado por event.target
preventDefault em atalho Não há formulário aqui: a resposta é um botão comum, e o quiz nunca submete nada
LocalStorage Persistência dos 5 melhores recordes
setInterval Timer de 20 segundos por pergunta
clearInterval Parar timer ao responder ou esgotar
Guarda de idempotência A flag respondeu barra o clique repetido — é um if no começo da função, não um debounce
Animações CSS via JS Transições de tela e entrada de elementos

Com este projeto o Módulo 2 se encerra. O Módulo 3 começa respondendo a uma pergunta que ficou pendente desde os temporizadores: se o JavaScript faz uma coisa de cada vez, como um setTimeout consegue esperar sem travar a página? A resposta é o Event Loop.

Fontes e Referências

Exercícios

Exercício 1

Este era o embaralhador do quiz antes da correção. Rode-o dez mil vezes com quatro cartas e conte quantas vezes cada carta cai na primeira posição. Os quatro números batem?

function embaralhar(array) {
  return [...array].sort(() => Math.random() - 0.5);
}
Ver resposta

✓ Resposta: Não batem — e a diferença é grande o bastante para ver a olho nu. O sort não é um sorteio: é um algoritmo de ordenação que faz um número limitado de comparações e confia que a função passada seja consistente, isto é, que ela responda sempre a mesma coisa para o mesmo par e que não se contradiga entre pares. Math.random() - 0.5 viola as duas garantias, e o que sai depende do algoritmo interno do motor: no V8, arrays pequenos usam ordenação por inserção, e o resultado tende a manter cada elemento perto de onde estava. Na prática, a primeira pergunta do quiz aparecia em primeiro lugar muito mais vezes do que uma em quatro. O algoritmo correto é o Fisher-Yates: percorra do fim para o começo e troque cada posição com uma sorteada entre as ainda não visitadas. São quatro linhas, cada ordem tem exatamente a mesma probabilidade, e o custo é linear em vez do n log n do sort.

Exercício 2

O quiz dá 100 pontos por acerto mais 10 por segundo restante. Um jogador responde certo faltando 12,4 segundos. Quantos pontos ele ganha — e o que acontece se ele responder no exato instante do estouro?

function calcularPontos(tempoRestante, acertou) {
  if (!acertou) return 0;
  return 100 + Math.floor(tempoRestante * 10);
}
Ver resposta

✓ Resposta: Ganha 224 pontos: 12.4 * 10 é 124, o Math.floor o mantém em 124, e somam-se os 100 da base. No instante do estouro tempoRestante vale 0, e o acerto ainda renderia os 100 pontos da base — mas ele nunca chega lá, porque timeoutPergunta() marca estado.respondeu = true e a primeira linha de responder() devolve imediatamente. A corrida entre os dois é resolvida por essa única flag, e é o que impede o quiz de contar ponto e tempo duas vezes na mesma pergunta. Repare ainda no efeito colateral simpático do Math.floor sobre um valor fracionário: como o tempo restante é medido em segundos com casas decimais, dois jogadores que respondem com quatro décimos de diferença podem receber a mesma pontuação — o arredondamento para baixo funciona como uma faixa, e isso é bom, porque a alternativa seria premiar a latência da rede em vez do conhecimento.

Exercício 3

Esta era a versão original do timer, antes da correção. Ela conta os 20 segundos corretamente?

estado.timerIntervalo = setInterval(() => {
  estado.tempoRestante -= 0.1;
  // ...
}, 100);

// e, ao responder:
const tempoGasto = 20 - estado.tempoRestante;
Ver resposta

✓ Resposta: Não — e o defeito é o mesmo que o artigo anterior tinha acabado de ensinar a evitar. São duas falhas somadas. A primeira é a de sempre nos temporizadores: 100 significa "não antes de 100 ms", cada tique chega um pouco atrasado, e como nada volta a consultar o relógio de verdade, o atraso se acumula — a pergunta de 20 segundos dura 21, 22, e numa aba em segundo plano bem mais. A segunda é aritmética: 0.1 não tem representação exata em ponto flutuante binário, então subtraí-lo duzentas vezes não chega a zero, e sim a um resíduo minúsculo; o código original precisava do teste <= 0 e de forçar tempoRestante = 0 justamente para disfarçar isso. Some as duas e o tempoGasto gravado no histórico fica errado, contaminando a estatística de tempo médio da tela final. A correção usa o estado.inicioPergunta que já estava sendo guardado: cada tique calcula 20 - (Date.now() - inicioPergunta) / 1000. O tique deixa de ser a fonte da verdade e vira só um pedido de repintura.

Exercício 4

Cada pergunta cria quatro botões novos, cada um com o seu listener, e a pergunta seguinte apaga tudo com innerHTML = "". Ao fim das dez perguntas, quantos listeners de clique ficaram pendurados na memória?

el.opcoes.innerHTML = "";

pergunta.opcoes.forEach((opcao, index) => {
  const btn = document.createElement("button");
  btn.textContent = opcao;
  btn.addEventListener("click", () => responder(index));
  el.opcoes.appendChild(btn);
});
Ver resposta

✓ Resposta: Nenhum. Aqui o innerHTML = "" é seguro, e vale entender por quê, já que noutros contextos ele é exatamente a origem do vazamento. Ao esvaziar o contêiner, os quatro botões saem do documento e ninguém mais guarda referência a eles: o coletor de lixo leva o elemento, e o listener registrado nele vai junto. Listener não é um recurso à parte que precise ser desfeito à mão — ele morre com o nó, desde que nada externo continue apontando para esse nó. O vazamento aparece quando algo fora mantém a referência viva, e o caso mais comum é justamente um temporizador: um setInterval cujo callback usa o elemento impede a coleta e continua rodando depois que o elemento saiu da tela. Dito isso, recriar quarenta botões ao longo do quiz é trabalho que a delegação dispensaria — um único listener em #opcoes, lendo event.target, atenderia as dez perguntas.

Exercício 5

O jogador faz 850 pontos, mas a lista de recordes já tem cinco entradas melhores. O que a função grava?

function salvarRecorde(pontuacao, acertos) {
  const recordes = storage.get("quiz-recordes", []);
  recordes.push({ pontuacao, acertos, data: new Date().toLocaleDateString("pt-BR") });
  recordes.sort((a, b) => b.pontuacao - a.pontuacao);
  storage.set("quiz-recordes", recordes.slice(0, 5));
}
Ver resposta

✓ Resposta: Grava os mesmos cinco de antes. A partida entra na lista, a ordenação a empurra para a sexta posição e o slice(0, 5) a descarta — o resultado escrito de volta é idêntico ao que já estava lá. O desenho é correto e vale reparar em três decisões dele. Primeira: o sort compara b.pontuacao - a.pontuacao, subtração numérica, e não a comparação padrão do sort, que converteria tudo em texto e colocaria "90" à frente de "850". Segunda: o slice devolve um array novo em vez de encurtar o existente, então o array em memória não é mutado — o que aqui não faz diferença, já que ele foi lido do armazenamento agora mesmo, mas é o hábito que evita surpresa quando a mesma lista está sendo exibida na tela. Terceira: em caso de empate, o sort do JavaScript é estável desde a especificação de 2019, então a partida antiga permanece à frente da nova — o que é o desejável para um recorde.

Comentários

Mais em Javascript

Criando um servidor HTTP com Node.js puro
Criando um servidor HTTP com Node.js puro

Um require de http, uma função de dois argumentos, e o servidor está de pé. O…

Async/Await: escrevendo código assíncrono de forma limpa
Async/Await: escrevendo código assíncrono de forma limpa

A cadeia de .then resolveu a pirâmide mas espalhou os valores por escopos…

Escopo, Hoisting e Closures
Escopo, Hoisting e Closures

Um laço com var e um setTimeout dentro imprime 4, 4, 4 — e explicar esse…