Files
Forge-Engine/static/js/play/quiz-box-templates.js
T
williamandClaude Sonnet 5 7db4803b93
Build and deploy / test-python (push) Successful in 12m10s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Ajoute la fonctionnalite quiz autonome/plein ecran a la boite a quiz
Introduit la double categorie de modeles (boite de dialogue / page de
quiz plein ecran) avec plein ecran, minuteur, score integre et ecran de
resultat pour les modeles page ; ajoute les modeles "Manga" (boite et
page) et "Classique" (page), pilotables aussi par l'assistant IA Ruby.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:07:23 +02:00

166 lines
9.1 KiB
JavaScript

// ---------- Effets visuels par modèle de "❓ Boîte à quiz" ----------
// Registre appelé par static/js/play/dialogue-box-controller.js (JAMAIS
// l'inverse) aux points d'extension du moteur PARTAGÉ (forgeShowQuizBox,
// forgeQuizBoxAnswer) — voir screens/rendering/quiz_box_templates.py
// pour le HTML/CSS de chaque modèle. Ajouter un futur modèle n'oblige
// donc jamais à toucher dialogue-box-controller.js : juste une nouvelle
// entrée ici (optionnelle) + son CSS/HTML.
// Effet PARTAGÉ entre plusieurs modèles "page de quiz" (Manga, Classique,
// et tout futur modèle qui voudrait la même mécanique) — demande
// explicite : au-dessus de la question, "Question N/M" (nombre de
// QUESTIONS de ce dialogue, pas la position brute dans lines qui mélange
// aussi les répliques) plutôt que le nom de l'objet déclencheur — et une
// barre d'étapes juste en dessous du bandeau. `forgeQuizBoxState`/
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
// chargé à côté de ce fichier) sont des variables GLOBALES de script
// classique (pas de module), lisibles ici telles quelles.
// `progressSelector` : chaque modèle a son propre conteneur de barre
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
// décoration propre à CHAQUE modèle, pas du contrat partagé).
function forgeQuizProgressOnShow(widget, progressSelector) {
if (!widget) return;
var quizObjectId = widget.getAttribute('data-object-id');
var quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
if (!quizState) return;
var dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
if (!dialogueState) return;
function isQuestion(line) { return line && line.type === 'question'; }
var total = dialogueState.lines.filter(isQuestion).length;
if (total === 0) return;
var current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
var header = widget.querySelector('[data-quiz-role="header"]');
if (header) header.textContent = 'Question ' + current + '/' + total;
var progressEl = widget.querySelector(progressSelector);
if (progressEl) {
var bars = [];
for (var i = 1; i <= total; i++) {
bars.push('<i class="' + (i < current ? 'is-done' : (i === current ? 'is-current' : '')) + '"></i>');
}
progressEl.innerHTML = bars.join('');
}
}
function forgeQuizMangaOnShow(widget) {
// Défensif : si CE widget est réutilisé pour un quiz déclenché à
// nouveau plus tard dans la partie (voir forgeQuizResultQuit,
// dialogue-box-controller.js — le widget lui-même n'est jamais
// reconstruit, juste masqué), une question fraîchement affichée ne
// doit jamais rester sur l'écran de résultat de la fois précédente.
if (widget) widget.classList.remove('is-showing-result');
forgeQuizProgressOnShow(widget, '[data-manga-role="progress"]');
}
function forgeQuizClassiqueOnShow(widget) {
// Même garde-fou que forgeQuizMangaOnShow ci-dessus, ici posée sur
// .classiqueContent (pas la racine du widget, voir
// render_quiz_box_page_classique) plutôt que sur le widget lui-même.
if (widget) {
var content = widget.querySelector('.classiqueContent');
if (content) content.classList.remove('is-showing-result');
}
forgeQuizProgressOnShow(widget, '[data-classique-role="progress"]');
}
// Écrans de résultat de fin de quiz ("classique" ci-dessous, "manga"
// plus bas) — appelés par forgeQuizPrepareResultScreen
// (dialogue-box-controller.js) UNIQUEMENT à la toute dernière question
// (ou si le minuteur du quiz ENTIER expire). `result.correctCount`/
// `result.totalCount` : comptés PAR CE QUIZ (voir forgeQuizBoxState dans
// dialogue-box-controller.js), jamais window.forgeQuizScore/
// forgeQuizCorrectAnswered qui cumulent TOUTE la partie. Le seuil de
// réussite réutilise EXACTEMENT window.FORGE_SUCCESS_THRESHOLD_PERCENT
// (déjà utilisé pour le statut SCORM final, voir
// forgeComputeFinalScormStatus) plutôt qu'un réglage à part. Le bouton
// QUITTE le quiz (jamais ne le recommence, demande explicite) — voir
// forgeQuizResultQuit, dialogue-box-controller.js.
function forgeQuizClassiqueOnQuizComplete(widget, result) {
if (!widget) return;
var content = widget.querySelector('.classiqueContent');
var zone = widget.querySelector('[data-classique-role="result"]');
if (!content || !zone) return;
var total = (result && result.totalCount) || 0;
var correct = (result && result.correctCount) || 0;
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
var passed = pct >= threshold;
var titleEl = widget.querySelector('.classiqueHeaderTitle');
var title = titleEl ? titleEl.textContent : 'Quiz';
var quizObjectId = widget.getAttribute('data-object-id');
zone.innerHTML =
'<div class="classiqueResultRing" style="--pct:' + pct + '"><span>' + pct + '%</span></div>' +
'<p class="classiqueResultTitle">' + correct + ' bonne' + (correct > 1 ? 's' : '') + ' réponse' + (correct > 1 ? 's' : '') + ' sur ' + total + '</p>' +
'<p class="classiqueResultSub">« ' + (typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title) + ' »</p>' +
'<div class="classiqueResultBadge ' + (passed ? 'pass' : 'fail') + '">' +
(passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') +
'</div>' +
'<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">✕ Quitter le quiz</button>';
content.classList.add('is-showing-result');
}
// "Manga" — même mécanique que forgeQuizClassiqueOnQuizComplete
// ci-dessus, thème manga propre à ce modèle (voir static/style.css) :
// pas de conteneur "content" à part ici (contrairement à
// .classiqueContent), la classe "is-showing-result" bascule sur la
// RACINE du widget elle-même (voir render_quiz_box_page_manga). La
// barre d'étapes est reposée à 100% "terminé" (tous les segments
// is-done) : forgeQuizProgressOnShow ne le fait pas toute seule, sa
// dernière question reste "is-current" (jamais "is-done", il n'y a
// plus de question suivante vers laquelle avancer) — un quiz FINI doit
// visuellement se lire comme entièrement complété.
function forgeQuizMangaOnQuizComplete(widget, result) {
if (!widget) return;
var zone = widget.querySelector('[data-manga-role="result"]');
if (!zone) return;
var total = (result && result.totalCount) || 0;
var correct = (result && result.correctCount) || 0;
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
var passed = pct >= threshold;
var quizObjectId = widget.getAttribute('data-object-id');
zone.innerHTML =
'<div class="mangaResultCard">' +
'<span class="mangaResultTitle">Fin du quiz</span>' +
'<p class="mangaResultScore">Score final : ' + correct + ' / ' + total + '</p>' +
'<div class="mangaResultBadge ' + (passed ? 'pass' : 'fail') + '">' + (passed ? '合格' : '不合格') + '</div>' +
'<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">Quitter le quiz</button></div>' +
'</div>';
var progressEl = widget.querySelector('[data-manga-role="progress"]');
if (progressEl && total > 0) {
var bars = [];
for (var i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
progressEl.innerHTML = bars.join('');
}
widget.classList.add('is-showing-result');
}
var forgeQuizTemplateEffects = {
// Modèles "boîte de dialogue" (voir screens/rendering/quiz_box_config.py
// ::QUIZ_BOX_DIALOG_TEMPLATES) — "Verre dépoli"/"Néon"/"Minimal"/
// "Ludique" ont été retirés (demande explicite : "supprime tous les
// modele de boite sauf le base"). "manga_dialogue" (clé DIFFÉRENTE du
// modèle "page" "manga" ci-dessous, même registre partagé) reste
// proche de "defaut" — aucun effet JS particulier, juste sa propre
// décoration CSS (voir render_quiz_box_manga_dialogue).
defaut: {},
manga_dialogue: {},
// Modèles "page de quiz" (QUIZ_BOX_PAGE_TEMPLATES) — le tampon "正解"/
// "不正解" du modèle "manga" est posé en CSS pur (::after sur
// .is-correct/.is-wrong, voir static/style.css), seul "Question N/M" +
// la barre d'étapes ont besoin d'un vrai calcul JS (onShow ci-dessus).
manga: { onShow: forgeQuizMangaOnShow, onQuizComplete: forgeQuizMangaOnQuizComplete },
classique: { onShow: forgeQuizClassiqueOnShow, onQuizComplete: forgeQuizClassiqueOnQuizComplete },
};
// static/js/play/__tests__/ (node:test) — même garde que le reste de
// static/js/play/.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeQuizTemplateEffects,
forgeQuizProgressOnShow, forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
};
}