Annuaire › Code ouvert
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
};
})();