◆ 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/ui.js · 14.1 Ko

/* ============================================================
   Fichier : js/ui.js
   Rôle    : l'AFFICHAGE. Ce fichier lit l'état du jeu (state) et
             dessine la page ; il remonte les actions de l'utilisateur
             (clics, touches) au chef d'orchestre (main.js).
             Il ne calcule aucun score et ne décide d'aucune règle.
   Dépend de : data/questions.js (NIVEAUX) et des id de index.html.
   Expose : l'objet global « UI » avec init() et afficher().
   Utilisé par : js/main.js.
   ------------------------------------------------------------
   Séparation des rôles :
     - Quiz (quiz.js) : décide.   - UI (ce fichier) : montre.
   Pour changer le design, on ne touche qu'à ce fichier et au CSS.
   ============================================================ */

const UI = (function () {

  /* ----------------------------------------------------------
     1. ÉLÉMENTS DE LA PAGE
     On les retrouve une seule fois, ici, puis on les réutilise.
     ---------------------------------------------------------- */
  const el = {
    ecranAccueil:  document.getElementById("ecran-accueil"),
    ecranQuestion: document.getElementById("ecran-question"),
    ecranFin:      document.getElementById("ecran-fin"),
    titreAccueil:  document.getElementById("titre-accueil"),
    titreFin:      document.getElementById("titre-fin"),

    formAccueil:   document.getElementById("formulaire-accueil"),
    listeNiveaux:  document.getElementById("liste-niveaux"),
    listeMeilleurs: document.getElementById("liste-meilleurs"),

    progression:   document.getElementById("info-progression"),
    score:         document.getElementById("info-score"),
    minuteur:      document.querySelector(".minuteur"),
    barreTemps:    document.getElementById("barre-temps"),
    texteTemps:    document.getElementById("texte-temps"),
    question:      document.getElementById("texte-question"),
    listeChoix:    document.getElementById("liste-choix"),
    zoneRetour:    document.getElementById("zone-retour"),
    texteRetour:   document.getElementById("texte-retour"),
    boutonSuivant: document.getElementById("bouton-suivant"),
    boutonAbandon: document.getElementById("bouton-abandonner"),

    resumeFin:     document.getElementById("resume-fin"),
    messageRecord: document.getElementById("message-record"),
    boutonRejouer: document.getElementById("bouton-rejouer"),

    annonce:       document.getElementById("annonce")
  };

  /** Formateur de nombres français (1 230 au lieu de 1230). */
  const formatNombre = new Intl.NumberFormat("fr-FR");

  /** Vrai après le tout premier affichage : évite de voler le focus au chargement de la page. */
  let dejaAffiche = false;

  /** Nombre de propositions affichées (pour les touches 1 à 4). */
  let nbChoix = 0;

  /** Phase du jeu connue (copie pour le gestionnaire de touches). */
  let phaseCourante = "accueil";

  /* ----------------------------------------------------------
     2. PETITS OUTILS
     ---------------------------------------------------------- */

  /**
   * Affiche un seul des trois écrans et cache les autres.
   * @param {HTMLElement} ecran - l'écran à montrer
   * @returns {void}
   */
  function montrerEcran(ecran) {
    [el.ecranAccueil, el.ecranQuestion, el.ecranFin].forEach(function (e) {
      e.hidden = (e !== ecran); // hidden = true cache l'élément
    });
  }

  /**
   * Écrit un message dans la zone « aria-live » pour les lecteurs d'écran.
   * On vide d'abord puis on remplit : cela garantit que le même message
   * répété sera quand même relu.
   * @param {string} message - le texte à annoncer
   * @returns {void}
   */
  function annoncer(message) {
    el.annonce.textContent = "";
    // setTimeout très court : laisse le navigateur remarquer que la zone a été vidée
    setTimeout(function () { el.annonce.textContent = message; }, 50);
  }

  /**
   * Donne le focus à un élément, sans faire défiler brutalement la page.
   * @param {HTMLElement} element - l'élément à focaliser
   * @returns {void}
   */
  function focaliser(element) {
    element.focus({ preventScroll: false });
  }

  /* ----------------------------------------------------------
     3. ÉCRAN D'ACCUEIL
     ---------------------------------------------------------- */

  /**
   * Construit les boutons radio des niveaux à partir de NIVEAUX.
   * Pourquoi par JavaScript ? Pour ajouter un niveau, il suffit de
   * l'ajouter dans data/questions.js : la page se met à jour seule.
   * @param {string} niveauChoisi - niveau à cocher
   * @returns {void}
   */
  function afficherNiveaux(niveauChoisi) {
    el.listeNiveaux.textContent = "";
    Object.keys(NIVEAUX).forEach(function (cle) {
      const label = document.createElement("label");
      label.className = "niveau";

      const radio = document.createElement("input");
      radio.type = "radio";
      radio.name = "niveau";   // même nom = un seul radio coché à la fois
      radio.value = cle;
      radio.checked = (cle === niveauChoisi);

      const texte = document.createElement("span");
      const titre = document.createElement("strong");
      titre.textContent = NIVEAUX[cle].libelle;
      const detail = document.createElement("small");
      detail.textContent = NIVEAUX[cle].detail;
      texte.append(titre, detail);

      label.append(radio, texte);
      el.listeNiveaux.appendChild(label);
    });
  }

  /**
   * Affiche la liste des meilleurs scores.
   * @param {object} meilleurs - par exemple { facile: 1200, moyen: 0, difficile: 0 }
   * @returns {void}
   */
  function afficherMeilleurs(meilleurs) {
    el.listeMeilleurs.textContent = "";
    Object.keys(NIVEAUX).forEach(function (cle) {
      const li = document.createElement("li");
      const nom = document.createElement("span");
      nom.textContent = NIVEAUX[cle].libelle;
      const valeur = document.createElement("strong");
      valeur.textContent = meilleurs[cle] > 0 ? formatNombre.format(meilleurs[cle]) + " points" : "–";
      li.append(nom, valeur);
      el.listeMeilleurs.appendChild(li);
    });
  }

  /**
   * Dessine l'écran d'accueil.
   * @param {object} state - l'état du jeu
   * @returns {void}
   */
  function dessinerAccueil(state) {
    afficherNiveaux(state.niveau);
    afficherMeilleurs(state.meilleurs);
    montrerEcran(el.ecranAccueil);
    if (dejaAffiche) { focaliser(el.titreAccueil); }
  }

  /* ----------------------------------------------------------
     4. ÉCRAN DE QUESTION
     ---------------------------------------------------------- */

  /**
   * Met à jour le minuteur (texte et barre).
   * @param {object} state - l'état du jeu
   * @returns {void}
   */
  function dessinerTemps(state) {
    const total = NIVEAUX[state.niveau].secondes;
    el.texteTemps.textContent = state.tempsRestant + " s";
    el.barreTemps.max = total;
    el.barreTemps.value = state.tempsRestant;
    // Les 5 dernières secondes : couleur d'alerte (la classe est définie dans le CSS)
    el.minuteur.classList.toggle("urgent", state.tempsRestant <= 5);
  }

  /**
   * Dessine une nouvelle question et ses propositions.
   * @param {object} state - l'état du jeu
   * @returns {void}
   */
  function dessinerQuestion(state) {
    const question = state.questions[state.index];

    el.progression.textContent = "Question " + (state.index + 1) + " sur " + state.questions.length;
    el.score.textContent = "Score : " + formatNombre.format(state.score);
    el.question.textContent = question.question;

    // On reconstruit les boutons de réponse
    el.listeChoix.textContent = "";
    question.choix.forEach(function (texte, position) {
      const bouton = document.createElement("button");
      bouton.type = "button";
      bouton.className = "choix__bouton";
      bouton.dataset.position = String(position); // lu plus tard par la délégation d'événements
      bouton.textContent = (position + 1) + ". " + texte;
      el.listeChoix.appendChild(bouton);
    });
    nbChoix = question.choix.length;

    el.zoneRetour.hidden = true;
    el.texteRetour.textContent = "";
    el.zoneRetour.className = "retour";
    el.boutonAbandon.hidden = false;

    dessinerTemps(state);
    montrerEcran(el.ecranQuestion);
    // Le focus sur la question : un lecteur d'écran la lit, et un utilisateur du clavier
    // n'a plus qu'à appuyer sur Tab (ou 1 à 4) pour répondre.
    focaliser(el.question);
  }

  /**
   * Montre le résultat de la réponse : couleurs, symboles et explication.
   * @param {object} state - l'état du jeu
   * @returns {void}
   */
  function dessinerReponse(state) {
    const question = state.questions[state.index];

    // On parcourt les boutons pour les marquer
    Array.from(el.listeChoix.children).forEach(function (bouton, position) {
      bouton.disabled = true; // on ne peut plus répondre
      if (position === question.bonne) {
        bouton.classList.add("bonne");
        bouton.textContent = "✔ " + bouton.textContent; // symbole : l'information ne repose pas que sur la couleur
      } else if (position === state.choisi) {
        bouton.classList.add("mauvaise");
        bouton.textContent = "✖ " + bouton.textContent;
      }
    });

    // Message de retour
    let message;
    if (state.correct) {
      message = "Bonne réponse ! +" + formatNombre.format(state.gain) + " points. ";
    } else if (state.choisi === -1) {
      message = "Temps écoulé ! La bonne réponse était : " + question.choix[question.bonne] + ". ";
    } else {
      message = "Raté. La bonne réponse était : " + question.choix[question.bonne] + ". ";
    }
    message += question.explication;

    el.texteRetour.textContent = message;
    el.zoneRetour.className = "retour " + (state.correct ? "ok" : "ko");
    el.zoneRetour.hidden = false;

    const derniere = (state.index + 1 === state.questions.length);
    el.boutonSuivant.textContent = derniere ? "Voir le résultat" : "Question suivante";
    el.score.textContent = "Score : " + formatNombre.format(state.score);
    el.boutonAbandon.hidden = true;

    annoncer(message);
    focaliser(el.boutonSuivant); // Entrée suffit pour continuer
  }

  /* ----------------------------------------------------------
     5. ÉCRAN DE FIN
     ---------------------------------------------------------- */

  /**
   * Dessine l'écran de fin de partie.
   * @param {object} state - l'état du jeu
   * @returns {void}
   */
  function dessinerFin(state) {
    el.resumeFin.textContent = state.bonnes + " bonne(s) réponse(s) sur " + state.questions.length +
      " – " + formatNombre.format(state.score) + " points (niveau " + NIVEAUX[state.niveau].libelle.toLowerCase() + ").";
    el.messageRecord.textContent = state.nouveauRecord
      ? "Nouveau meilleur score à ce niveau !"
      : "Meilleur score à ce niveau : " + formatNombre.format(state.meilleurs[state.niveau]) + " points.";
    montrerEcran(el.ecranFin);
    annoncer(el.resumeFin.textContent + " " + el.messageRecord.textContent);
    focaliser(el.titreFin);
  }

  /* ----------------------------------------------------------
     6. POINT D'ENTRÉE : réagir aux événements du Quiz
     ---------------------------------------------------------- */

  /**
   * Redessine l'écran en fonction de l'événement reçu du Quiz.
   * @param {string} evenement - "accueil", "question", "tick", "reponse" ou "fin"
   * @param {object} state     - l'état du jeu
   * @returns {void}
   */
  function afficher(evenement, state) {
    phaseCourante = state.phase;
    switch (evenement) {
      case "accueil":  dessinerAccueil(state); break;
      case "question": dessinerQuestion(state); break;
      case "tick":
        dessinerTemps(state);
        // On n'annonce pas chaque seconde (ce serait insupportable) : seulement 10 s et 5 s.
        if (state.tempsRestant === 10 || state.tempsRestant === 5) {
          annoncer("Il reste " + state.tempsRestant + " secondes.");
        }
        break;
      case "reponse":  dessinerReponse(state); break;
      case "fin":      dessinerFin(state); break;
    }
    dejaAffiche = true;
  }

  /* ----------------------------------------------------------
     7. ENTRÉES DE L'UTILISATEUR
     ---------------------------------------------------------- */

  /**
   * Branche les écouteurs d'événements. Les actions réelles sont
   * fournies par main.js sous forme de fonctions (« rappels »), ce qui
   * garde UI indépendant de Quiz.
   * @param {{surDemarrer:function(string):void, surChoix:function(number):void,
   *          surSuivante:function():void, surAbandon:function():void,
   *          surSon:function(boolean):void}} actions - les fonctions à appeler
   * @returns {void}
   */
  function init(actions) {

    // Choix du niveau + bouton Commencer (un formulaire : Entrée fonctionne aussi)
    el.formAccueil.addEventListener("submit", function (evenement) {
      evenement.preventDefault();
      const coche = el.formAccueil.querySelector("input[name='niveau']:checked");
      actions.surDemarrer(coche ? coche.value : "facile");
    });

    // DÉLÉGATION : un seul écouteur sur le parent des boutons de réponse
    el.listeChoix.addEventListener("click", function (evenement) {
      const bouton = evenement.target.closest(".choix__bouton");
      if (!bouton || bouton.disabled) { return; }
      actions.surChoix(Number(bouton.dataset.position));
    });

    el.boutonSuivant.addEventListener("click", actions.surSuivante);
    el.boutonAbandon.addEventListener("click", actions.surAbandon);
    el.boutonRejouer.addEventListener("click", actions.surAbandon); // revient à l'accueil pour rechoisir

    // Case « effets sonores »
    document.getElementById("option-son").addEventListener("change", function (evenement) {
      actions.surSon(evenement.target.checked);
    });

    // Raccourcis clavier : touches 1 à 4 pendant une question
    document.addEventListener("keydown", function (evenement) {
      if (phaseCourante !== "question") { return; }
      if (evenement.ctrlKey || evenement.altKey || evenement.metaKey) { return; } // laisse passer Ctrl+1 etc.
      const numero = Number(evenement.key); // "2" -> 2 ; une lettre -> NaN
      if (numero >= 1 && numero <= nbChoix) {
        actions.surChoix(numero - 1);
      }
    });
  }

  return { init: init, afficher: afficher };
})();