◆ Portail Antalaha

Annuaire › Code ouvert

Quiz Antalaha et Madagascar

tutorial · Voir le site ↗
Connectez-vous pour copier ce site

Code publié par son auteur, en lecture seule. « Copier dans mon espace » crée un nouveau site à vous (non publié) que vous pouvez modifier librement.

js/quiz.js · 10.7 Ko

/* ============================================================
   Fichier : js/quiz.js
   Rôle    : la LOGIQUE du quiz (règles, score, minuteur, meilleurs
             scores). Ce fichier ne touche JAMAIS à la page : pas de
             document, pas de getElementById. Il pourrait fonctionner
             avec un autre affichage (ou être testé sans navigateur).
   Dépend de : data/questions.js (NIVEAUX, QUESTIONS, NB_QUESTIONS_PAR_PARTIE).
   Expose : l'objet global « Quiz ».
   Utilisé par : js/ui.js (lit Quiz.state) et js/main.js (appelle les actions).
   ------------------------------------------------------------
   ARCHITECTURE EN 3 COUCHES
     data/questions.js  -> les DONNÉES
     js/quiz.js         -> la LOGIQUE  (ce fichier)
     js/ui.js           -> l'AFFICHAGE
     js/main.js         -> le CHEF D'ORCHESTRE qui les relie

   COMMENT LA LOGIQUE « PARLE » À L'AFFICHAGE ?
   Par des événements (patron « observateur ») : l'extérieur appelle
   Quiz.abonner(fonction). À chaque changement important, Quiz appelle
   cette fonction en donnant le nom de l'événement :
     "accueil" | "question" | "tick" | "reponse" | "fin"
   ============================================================ */

const Quiz = (function () {

  /* ----------------------------------------------------------
     1. L'ÉTAT UNIQUE (« state »)
     Toute la situation du jeu est rangée dans CET objet et nulle
     part ailleurs. À tout moment, on peut redessiner l'écran en
     lisant seulement state. C'est ce qui évite les incohérences.
     ---------------------------------------------------------- */
  const state = {
    phase: "accueil",   // "accueil" | "question" | "reponse" | "fin"
    niveau: "facile",   // niveau choisi
    questions: [],      // les questions de la partie en cours (déjà mélangées)
    index: 0,           // position de la question courante (0 = première)
    score: 0,           // points cumulés
    bonnes: 0,          // nombre de bonnes réponses
    tempsRestant: 0,    // secondes restantes pour la question courante
    choisi: null,       // position de la proposition choisie (-1 = temps écoulé, null = pas encore)
    correct: false,     // la dernière réponse était-elle bonne ?
    gain: 0,            // points gagnés par la dernière réponse
    nouveauRecord: false, // la partie terminée bat-elle le meilleur score ?
    meilleurs: { facile: 0, moyen: 0, difficile: 0 } // meilleur score par niveau
  };

  /** Fonctions à prévenir à chaque événement (voir abonner). */
  const abonnes = [];

  /** Identifiant du minuteur (setInterval), pour pouvoir l'arrêter. */
  let minuteur = null;

  /** Clé de sauvegarde des meilleurs scores. */
  const CLE_MEILLEURS = "quiz-antalaha-meilleurs";

  /** Points de base d'une bonne réponse, et bonus par seconde restante. */
  const POINTS_BASE = 100;
  const POINTS_PAR_SECONDE = 10;

  /* ----------------------------------------------------------
     2. STOCKAGE DES MEILLEURS SCORES (sûr dans le bac à sable)
     PIÈGE : localStorage lève une SecurityError dans le bac à sable
     du Portail. try/catch + mémoire de secours : le jeu fonctionne,
     mais le record ne survit pas au rechargement de la page.
     ---------------------------------------------------------- */
  let memoireMeilleurs = null; // copie en mémoire (texte JSON)

  /**
   * Charge les meilleurs scores depuis le stockage (ou la mémoire).
   * @returns {void}
   */
  function chargerMeilleurs() {
    let texte = memoireMeilleurs;
    try {
      const lu = window.localStorage.getItem(CLE_MEILLEURS);
      if (lu) { texte = lu; }
    } catch (erreur) { /* stockage interdit : on garde la mémoire */ }
    if (!texte) { return; }
    try {
      const objet = JSON.parse(texte);
      // On ne retient que des nombres valides, niveau par niveau
      Object.keys(state.meilleurs).forEach(function (niveau) {
        const valeur = Number(objet[niveau]);
        if (Number.isFinite(valeur) && valeur > 0) { state.meilleurs[niveau] = valeur; }
      });
    } catch (erreur) { /* texte abîmé : on ignore */ }
  }

  /**
   * Enregistre les meilleurs scores.
   * @returns {void}
   */
  function sauverMeilleurs() {
    memoireMeilleurs = JSON.stringify(state.meilleurs);
    try {
      window.localStorage.setItem(CLE_MEILLEURS, memoireMeilleurs);
    } catch (erreur) { /* stockage interdit : la mémoire suffit */ }
  }

  /* ----------------------------------------------------------
     3. OUTILS
     ---------------------------------------------------------- */

  /**
   * Mélange un tableau avec l'algorithme de Fisher-Yates.
   * Principe : on parcourt le tableau de la fin vers le début ; à chaque
   * position i, on l'échange avec une position j tirée au hasard entre
   * 0 et i. Chaque ordre possible a la MÊME probabilité.
   * (À éviter : trier avec « sort(() => Math.random() - 0.5) », qui
   * donne un mélange biaisé.)
   * @param {Array} tableau - le tableau d'origine (il n'est PAS modifié)
   * @returns {Array} une NOUVELLE liste mélangée
   */
  function melanger(tableau) {
    const copie = tableau.slice(); // copie : on ne touche pas à l'original
    for (let i = copie.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1)); // entier de 0 à i inclus
      const temporaire = copie[i];                    // échange de copie[i] et copie[j]
      copie[i] = copie[j];
      copie[j] = temporaire;
    }
    return copie;
  }

  /**
   * Prépare une question pour la partie : mélange ses 4 propositions
   * et recalcule la position de la bonne réponse.
   * @param {{question:string, choix:string[], bonne:number, explication:string}} q - question brute
   * @returns {{question:string, choix:string[], bonne:number, explication:string}} question prête à jouer
   */
  function preparerQuestion(q) {
    const bonneReponse = q.choix[q.bonne];        // on mémorise le TEXTE de la bonne réponse
    const choixMelanges = melanger(q.choix);
    return {
      question: q.question,
      choix: choixMelanges,
      bonne: choixMelanges.indexOf(bonneReponse), // où est-elle après mélange ?
      explication: q.explication
    };
  }

  /**
   * Prévient tous les abonnés qu'un événement a eu lieu.
   * @param {string} evenement - nom de l'événement
   * @returns {void}
   */
  function notifier(evenement) {
    abonnes.forEach(function (fonction) { fonction(evenement, state); });
  }

  /**
   * Arrête le minuteur s'il tourne.
   * @returns {void}
   */
  function arreterMinuteur() {
    if (minuteur !== null) {
      clearInterval(minuteur);
      minuteur = null;
    }
  }

  /**
   * Lance le minuteur de la question courante : une fois par seconde,
   * on retire 1 seconde. À zéro, la réponse est comptée comme ratée.
   * @returns {void}
   */
  function demarrerMinuteur() {
    arreterMinuteur(); // sécurité : jamais deux minuteurs en même temps
    state.tempsRestant = NIVEAUX[state.niveau].secondes;
    minuteur = setInterval(function () {
      state.tempsRestant -= 1;
      if (state.tempsRestant <= 0) {
        state.tempsRestant = 0;
        repondre(-1); // -1 = « aucune proposition choisie » (temps écoulé)
      } else {
        notifier("tick");
      }
    }, 1000);
  }

  /* ----------------------------------------------------------
     4. ACTIONS (appelées depuis l'extérieur)
     ---------------------------------------------------------- */

  /**
   * S'abonne aux événements du quiz.
   * @param {function(string, object):void} fonction - reçoit (nomEvenement, state)
   * @returns {void}
   */
  function abonner(fonction) {
    abonnes.push(fonction);
  }

  /**
   * Initialise le quiz : charge les records et annonce l'accueil.
   * @returns {void}
   */
  function init() {
    chargerMeilleurs();
    state.phase = "accueil";
    notifier("accueil");
  }

  /**
   * Démarre une partie au niveau demandé.
   * @param {string} niveau - "facile", "moyen" ou "difficile"
   * @returns {void}
   */
  function demarrer(niveau) {
    if (!NIVEAUX[niveau]) { niveau = "facile"; } // niveau inconnu : valeur par défaut
    state.niveau = niveau;

    // filter : on garde seulement les questions de ce niveau
    const reserve = QUESTIONS.filter(function (q) { return q.niveau === niveau; });
    // mélange, limite le nombre, puis mélange aussi les propositions de chaque question
    state.questions = melanger(reserve)
      .slice(0, NB_QUESTIONS_PAR_PARTIE)
      .map(preparerQuestion);

    state.index = 0;
    state.score = 0;
    state.bonnes = 0;
    state.choisi = null;
    state.correct = false;
    state.gain = 0;
    state.nouveauRecord = false;
    state.phase = "question";
    demarrerMinuteur();
    notifier("question");
  }

  /**
   * Enregistre la réponse du joueur à la question courante.
   * @param {number} position - position de la proposition choisie (-1 si temps écoulé)
   * @returns {void}
   */
  function repondre(position) {
    if (state.phase !== "question") { return; } // ignore un double clic ou une réponse tardive
    arreterMinuteur();

    const question = state.questions[state.index];
    state.choisi = position;
    state.correct = (position === question.bonne);

    if (state.correct) {
      // Plus on répond vite, plus on gagne de points
      state.gain = POINTS_BASE + state.tempsRestant * POINTS_PAR_SECONDE;
      state.score += state.gain;
      state.bonnes += 1;
    } else {
      state.gain = 0;
    }
    state.phase = "reponse";
    notifier("reponse");
  }

  /**
   * Passe à la question suivante, ou termine la partie s'il n'y en a plus.
   * @returns {void}
   */
  function suivante() {
    if (state.phase !== "reponse") { return; }
    if (state.index + 1 < state.questions.length) {
      state.index += 1;
      state.choisi = null;
      state.phase = "question";
      demarrerMinuteur();
      notifier("question");
    } else {
      terminer();
    }
  }

  /**
   * Termine la partie : met à jour le meilleur score si besoin.
   * @returns {void}
   */
  function terminer() {
    arreterMinuteur();
    state.phase = "fin";
    if (state.score > state.meilleurs[state.niveau]) {
      state.meilleurs[state.niveau] = state.score;
      state.nouveauRecord = true;
      sauverMeilleurs();
    }
    notifier("fin");
  }

  /**
   * Abandonne la partie en cours et revient à l'accueil.
   * @returns {void}
   */
  function retourAccueil() {
    arreterMinuteur();
    state.phase = "accueil";
    notifier("accueil");
  }

  // Ce que l'on rend public (state est partagé en lecture par ui.js ;
  // par convention, seul Quiz le MODIFIE).
  return {
    state: state,
    abonner: abonner,
    init: init,
    demarrer: demarrer,
    repondre: repondre,
    suivante: suivante,
    retourAccueil: retourAccueil,
    melanger: melanger
  };
})();