Files
Forge-Engine/static/js/play/dialogue-box-controller.js
T
williamandClaude Sonnet 5 c57420c8c9 Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

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

727 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
// Fait le lien entre l'éditeur de déclencheurs (action "dialogue", voir
// static/js/play/collision-rules-controller.js) et les widgets d'interface
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
// (action.dialogue.lines, transmises telles quelles par
// collision-rules-controller.js — chaque occurrence de "Déclencher ce
// dialogue" porte son propre dialogue, voir screens/rendering/
// collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche
// dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne
// "question" ("❓ Question", voir static/js/triggers/trigger-editor.js)
// s'affiche dans la boîte à QUIZ (si posée sur la scène — sinon ignorée
// silencieusement, la conversation avance sans elle) : cliquer la BONNE
// réponse crédite le score du montant prévu et avance ; une mauvaise
// réponse ne fait RIEN, la question reste affichée (le joueur peut
// réessayer).
//
// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
// globales et conditions (voir screens/rendering/collision_rules.py::
// "condition") — le dialogue choisi s'affiche directement. Une action
// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
// (ou immédiatement s'il est vide), son id est ajouté à
// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
// dialogues `mark_completed` du jeu joués, voir
// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
// lui-même reste un réglage du créateur totalement indépendant
// (db/games/get_success_threshold.py), inchangé ici.
//
// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié
// à ce système de dialogue/quiz — distinct du score "classique" du moteur
// document (db/scoring/, action de flow "Modifier un score"), qui
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors
// de propos pour ce sous-système entièrement client (voir
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
// serveur").
//
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
// l'écran — même esprit que le reste du moteur.
const forgeDialogueBoxState = {};
const forgeQuizBoxState = {};
window.forgeQuizScore = window.forgeQuizScore || 0;
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
// uniquement à calculer un taux de réussite global (voir
// forgeComputeFinalScormStatus), distinct de window.forgeQuizScore
// qui ne compte que les points gagnés.
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
// forgeSyncAllDialoguesCompletionToScorm.
window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
function forgeEscapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------- Boîte de dialogue ----------
function forgeDialogueBoxWidgetOnCurrentScreen() {
return document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .dialogueBoxWidget`);
}
function forgeDialogueBoxWidgetById(objectId) {
return document.querySelector(`.dialogueBoxWidget[data-object-id="${objectId}"]`);
}
function forgeDialogueBoxFooterEl(widget) {
return widget.querySelector('[data-dialogue-role="footer"]');
}
// Voix off d'une réplique (demande explicite : "ajouter une réplique
// audio", voir db/dialogue_lines.py::"dialogue".audio_url) — jouée dès
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
// sa propre voix off) prend sa place, pour ne jamais superposer deux
// voix off en même temps si le joueur avance vite dans la conversation.
let forgeDialogueLineAudio = null;
function forgeStopDialogueLineAudio() {
if (forgeDialogueLineAudio) {
forgeDialogueLineAudio.pause();
forgeDialogueLineAudio = null;
}
}
function forgeRenderDialogueBoxLine(widget, line) {
const header = widget.querySelector('[data-dialogue-role="header"]');
const body = widget.querySelector('[data-dialogue-role="body"]');
if (header) header.textContent = line.speaker;
if (body) body.textContent = line.text;
forgeStopDialogueLineAudio();
if (line.audio_url) {
forgeDialogueLineAudio = new Audio(line.audio_url);
// lecture bloquée par le navigateur (pas d'interaction récente) : la
// bulle reste lisible sans voix off plutôt que de planter.
forgeDialogueLineAudio.play().catch(() => {});
}
}
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
// affichée (voir forgeShowCurrentConversationLine).
function forgeResetDialogueBoxFooter(widget, objectId) {
const footer = forgeDialogueBoxFooterEl(widget);
if (footer) footer.innerHTML = `<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(${objectId})">Suivant →</button>`;
}
// ---------- Boîte à quiz ----------
function forgeQuizBoxWidgetOnCurrentScreen() {
const scoped = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .quizBoxWidget`);
if (scoped) return scoped;
// Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir
// forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus
// une fois la première question affichée — retrouvé via l'attribut
// posé au déplacement plutôt que par sa position dans le DOM.
return document.querySelector(`.quizBoxWidget[data-owner-screen-id="${window.currentScreenId}"]`);
}
function forgeQuizBoxWidgetById(objectId) {
return document.querySelector(`.quizBoxWidget[data-object-id="${objectId}"]`);
}
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene"
// (templates/play.html) est centré via `transform:translate(-50%,-50%)`,
// ce qui en fait la référence de positionnement ("containing block") de
// tout descendant `position:fixed` : le plein écran de la boîte à quiz
// ne couvrait donc que CE conteneur (toujours à la taille scene_width/
// scene_height, la "caméra"), jamais le VRAI viewport du navigateur.
// Seul un déplacement hors de ce conteneur (comme static/js/play/
// video-overlay.js le fait déjà pour son overlay) permet à
// `position:fixed` de couvrir tout l'écran pour de vrai. `data-owner-
// screen-id` (posé ici) permet à forgeQuizBoxWidgetOnCurrentScreen ci-
// dessus de le retrouver malgré le déplacement.
function forgeEscapeCameraForFullscreen(widget) {
if (widget.parentNode === document.body) return;
const screenEl = widget.closest('.playScreen');
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
widget.classList.add('quizBoxWidget--detachedForFullscreen');
document.body.appendChild(widget);
}
// Point de sortie UNIQUE de la boîte à quiz (fin d'une question réussie
// vers la ligne suivante, OU fin de tout le dialogue) — y arrêter aussi
// le minuteur/retirer le plein écran ici garantit qu'aucun des deux
// n'est jamais laissé actif par erreur, quel que soit le chemin de
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
function forgeHideQuizBoxOnCurrentScreen() {
const widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) return;
widget.classList.remove('is-active', 'is-fullscreen');
const objectId = widget.getAttribute('data-object-id');
forgeStopQuizTimer(objectId);
}
// ---------- Minuteur (plein écran, demande explicite : "un timer pour
// répondre aux questions") ----------
// Optionnel — "aucun" (screens/rendering/quiz_box_config.py) est le
// réglage par défaut, jamais imposé. Porté par data-timer-mode/
// data-timer-seconds sur le widget lui-même (voir screens/rendering/
// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de
// payload séparé — cohérent avec le reste de ce moteur (data-* posés au
// rendu, jamais un aller-retour serveur pour du pur affichage).
const forgeQuizTimerState = {};
function forgeStopQuizTimer(objectId) {
const t = forgeQuizTimerState[objectId];
if (t && t.intervalId) clearInterval(t.intervalId);
delete forgeQuizTimerState[objectId];
}
// mode "question" : redémarre à CHAQUE question (reset=true, appelé
// depuis forgeShowQuizBox). mode "quiz" : démarré UNE SEULE FOIS pour
// tout le quiz (reset=false une fois déjà en cours), continue de tourner
// d'une question à l'autre sans se réinitialiser.
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
forgeStopQuizTimer(objectId);
const state = { remaining: seconds };
forgeQuizTimerState[objectId] = state;
const timerEl = widget.querySelector('[data-quiz-role="timer"]');
function render() {
if (!timerEl) return;
const remaining = Math.max(0, state.remaining);
const m = Math.floor(remaining / 60); const
s = remaining % 60;
timerEl.textContent = `⏱ ${m}:${s < 10 ? '0' : ''}${s}`;
timerEl.classList.toggle('is-urgent', remaining <= 5);
}
render();
state.intervalId = setInterval(() => {
state.remaining -= 1;
render();
if (state.remaining <= 0) {
forgeStopQuizTimer(objectId);
onExpire();
}
}, 1000);
}
// ---------- Score ----------
function forgeUpdateAllScoreWidgets() {
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
forgeUpdateQuizBoxScoreDisplays();
}
// Score affiché DANS la boîte à quiz elle-même (demande explicite :
// "lorsque la boîte à quiz prend tout l'écran, le score s'affiche dans
// la boîte à quiz") — masqué par CSS hors plein écran (voir
// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
function forgeUpdateQuizBoxScoreDisplays() {
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
}
// Alimente aussi gameData.scoring (voir static/js/play/offline/
// apply-actions.js, même forme {score, status}) — bug signalé : "les
// indicateurs qu'on utilise pour les dialogues devraient aussi servir aux
// indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que
// static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser
// cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y
// toucher, un jeu construit uniquement avec des dialogues/quiz (jamais
// l'action de flow "Modifier un score") laissait Score/Completion
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
// (non_commence/en_cours/termine/...), voir FORGE_TO_SCORM_STATUS.
function forgeSyncQuizScoreToScorm() {
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
gameData.scoring.score = window.forgeQuizScore;
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
// dialogue/quiz contourne static/js/play/offline/apply-actions.js
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
// sans cet appel direct — même bug que le commentaire ci-dessus pour
// SCORM.
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(gameData.scoring.score);
}
// Un dialogue affiché = la partie a commencé, même sans le moindre point
// encore gagné — sans cet appel, Score restait "0" mais Completion
// restait "unknown" tant qu'aucune question n'avait encore été résolue.
// Appelé depuis forgeShowDialogueBox.
function forgeSyncDialogueStartedToScorm() {
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
}
// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
// marqués `mark_completed` (voir gameData.completable_dialogue_ids,
// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
// été joués (voir forgeAdvanceConversation) — règle simple et
// prévisible plutôt que de deviner lequel des dialogues créés "compte"
// pour la fin du jeu. Réussi/Échoué (voir db/games/get_success_threshold.py,
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
// build_scorm_package.py) : demande utilisateur — "il manque
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
// les dialogues terminés" qui est déjà la seule condition de fin de
// partie. Aucune question posée du tout (jeu fait uniquement de
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
function forgeComputeFinalScormStatus() {
const total = window.forgeQuizTotalAnswered || 0;
if (total === 0) return 'termine';
const percent = (window.forgeQuizCorrectAnswered / total) * 100;
const threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
return percent >= threshold ? 'reussi' : 'echoue';
}
function forgeSyncAllDialoguesCompletionToScorm() {
const completableIds = (gameData.completable_dialogue_ids || []);
const allDone = completableIds.length > 0 && completableIds.every((id) => !!window.forgeCompletedDialogueIds[id]);
if (!allDone) return;
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
const finalStatus = forgeComputeFinalScormStatus();
gameData.scoring.status = finalStatus;
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(finalStatus);
}
function forgeAddQuizScore(amount) {
window.forgeQuizScore += (amount || 0);
forgeUpdateAllScoreWidgets();
forgeSyncQuizScoreToScorm();
}
// ---------- Enchaînement dialogue/question ----------
// Affiche la ligne COURANTE de l'état de conversation `dialogueObjectId`
// (voir forgeDialogueBoxState) — une ligne "dialogue" dans la boîte de
// dialogue, une ligne "question" dans la boîte à quiz (voir
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
// n'est posée : impossible de bloquer la conversation indéfiniment).
function forgeShowCurrentConversationLine(dialogueObjectId) {
const state = forgeDialogueBoxState[dialogueObjectId];
if (!state) return;
const line = state.lines[state.index];
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (line.type === 'question') {
if (widget) widget.classList.remove('is-active');
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
return;
}
forgeHideQuizBoxOnCurrentScreen();
if (!widget) return;
forgeResetDialogueBoxFooter(widget, dialogueObjectId);
forgeRenderDialogueBoxLine(widget, line);
widget.classList.add('is-active');
}
// Appelée par l'action "dialogue" (collision-rules-controller.js) —
// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
// le porter. `markCompleted` : voir forgeAdvanceConversation ci-dessous.
// `thenAction` (chaînage, voir screens/rendering/collision_rules.py::
// "then") : l'action suivante, exécutée seulement une fois ce dialogue
// épuisé — jamais avant, contrairement à une action "variable" qui,
// elle, chaîne immédiatement (voir collision-rules-controller.js).
function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) {
if (!dialogueId) return;
lines = lines || [];
// Bug corrigé : "j'ai créé un quiz à l'affichage de l'écran, rien ne
// s'affiche" — un dialogue fait UNIQUEMENT de questions (quiz RH
// autonome, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de dialogue"
// posée sur l'écran, seulement d'une "❓ Boîte à quiz" — cette fonction
// sortait pourtant en silence dès le départ s'il n'y avait aucune boîte
// de dialogue, empêchant même la première question de s'afficher. Sans
// widget réel pour porter la clé d'état, `dialogueId` sert de repli
// (unique par action "Déclencher ce dialogue") — une ligne "réplique"
// sans boîte de dialogue posée reste ignorée plus loin (voir
// forgeShowCurrentConversationLine), même logique symétrique qu'une
// question sans boîte à quiz posée (voir forgeShowQuizBox).
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
forgeSyncDialogueStartedToScorm();
if (!lines.length) {
// Rien à jouer : applique quand même markCompleted PUIS thenAction
// immédiatement (rien à exhiber avant), sinon silencieux.
if (markCompleted) forgeCompleteDialogue(dialogueId, objectName);
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
return;
}
forgeDialogueBoxState[stateKey] = {
dialogueId,
objectName,
lines,
index: 0,
markCompleted: !!markCompleted,
thenAction,
};
forgeShowCurrentConversationLine(stateKey);
}
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
// le créateur pilote lui-même quand, via la case "Marquer ce dialogue
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
function forgeCompleteDialogue(dialogueId, objectName) {
window.forgeCompletedDialogueIds[dialogueId] = true;
if (typeof forgeXapiNotifyDialogueCompleted === 'function') forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
forgeSyncAllDialoguesCompletionToScorm();
}
// Avance d'une ligne (fin de dialogue -> question, fin de question
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par
// une bonne réponse au quiz (voir forgeQuizBoxAnswer). À l'épuisement des
// lignes : markCompleted PUIS thenAction (voir screens/rendering/
// collision_rules.py::"then", demande explicite : "pouvoir modifier un
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
// dialogue est fini", jamais avant.
function forgeAdvanceConversation(dialogueObjectId) {
const state = forgeDialogueBoxState[dialogueObjectId];
if (!state) return;
state.index += 1;
if (state.index >= state.lines.length) {
forgeStopDialogueLineAudio();
forgeHideQuizBoxOnCurrentScreen();
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
if (widget) widget.classList.remove('is-active');
delete forgeDialogueBoxState[dialogueObjectId];
return;
}
forgeShowCurrentConversationLine(dialogueObjectId);
}
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)") — sans
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
function forgeDialogueBoxAdvance(objectId) {
const state = forgeDialogueBoxState[objectId];
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
forgeAdvanceConversation(objectId);
}
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
// Informations"), corps = la question + ses choix — si l'auteur n'a posé
// aucune boîte à quiz, la question est ignorée (avance directement)
// plutôt que de bloquer la conversation pour toujours en attendant une
// réponse qu'aucun widget ne peut recueillir.
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
const widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
const quizObjectId = widget.getAttribute('data-object-id');
const previousState = forgeQuizBoxState[quizObjectId];
forgeQuizBoxState[quizObjectId] = {
dialogueObjectId,
correctIndex: line.correct_index,
rewardAmount: line.reward_amount,
resolved: false,
dialogueId,
objectName,
questionIndex,
questionText: line.text,
// Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) :
// ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite
// sans se réinitialiser — ce drapeau survit d'une question à l'autre
// via l'état précédent de CE MÊME quiz.
quizTimerStarted: previousState ? previousState.quizTimerStarted : false,
// Score DE CE QUIZ (jamais window.forgeQuizScore/forgeQuizCorrectAnswered,
// cumulés sur TOUTE la partie, voir plus haut) — survit d'une
// question à l'autre comme quizTimerStarted : sert à afficher "X
// bonnes réponses sur Y" à la fin (voir forgeQuizBoxContinue/
// forgeQuizPrepareResultScreen plus bas).
correctCount: previousState ? previousState.correctCount : 0,
answeredCount: previousState ? previousState.answeredCount : 0,
};
const header = widget.querySelector('[data-quiz-role="header"]');
const questionEl = widget.querySelector('[data-quiz-role="question"]');
const choicesEl = widget.querySelector('[data-quiz-role="choices"]');
if (header) header.textContent = objectName || '';
if (questionEl) questionEl.textContent = line.text;
if (choicesEl) {
choicesEl.innerHTML = line.choices.map((choice, i) => `<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(${quizObjectId}, ${i})">${forgeEscapeHtml(choice)}</button>`).join('');
// "Continuer →" TOUJOURS présent dès la question (demande explicite :
// "le bouton continuer est toujours visible mais activé seulement
// quand un choix est fait", comme la maquette manga validée) —
// désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais
// recréé à ce moment-là, juste .disabled=false, voir plus bas).
choicesEl.innerHTML += `<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(${quizObjectId})" disabled>Continuer →</button>`;
}
// "Plein écran" (demande explicite) — reposé à CHAQUE question,
// sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen,
// static/style.css). Échappe d'abord au conteneur de scène transformé
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
const isFullscreen = widget.getAttribute('data-fullscreen') === '1';
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
widget.classList.toggle('is-fullscreen', isFullscreen);
forgeUpdateQuizBoxScoreDisplays();
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
// imposé, voir screens/rendering/quiz_box_config.py).
const timerMode = widget.getAttribute('data-timer-mode');
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
if (timerMode === 'question') {
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, true); });
} else if (timerMode !== 'quiz') {
forgeStopQuizTimer(quizObjectId);
}
// Rejoue l'animation d'entrée (@keyframes quizPopIn) même si la boîte
// était déjà "is-active" juste avant (2 questions à la suite) : sans
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
// son animation CSS.
widget.classList.remove('is-active');
void widget.offsetWidth;
widget.classList.add('is-active');
// Effet d'entrée propre au modèle (voir static/js/play/
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
const showFx = forgeQuizTemplateEffectsFor(widget).onShow;
if (showFx) showFx(widget);
}
// Registre d'effets par modèle (voir static/js/play/quiz-box-
// templates.js, chargé AVANT ce fichier) — absent en environnement de
// test qui ne charge pas ce script, d'où la garde `typeof`.
function forgeQuizTemplateEffectsFor(widget) {
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
}
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
// explicite : une mauvaise réponse ne doit jamais bloquer la
// progression) — surligne la bonne réponse en vert (et le choix cliqué
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
// second clic pendant cet affichage est ignoré (`resolved`).
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
const state = forgeQuizBoxState[quizObjectId];
if (!state || state.resolved) return;
state.resolved = true;
const widget = forgeQuizBoxWidgetById(quizObjectId);
const correct = choiceIndex === state.correctIndex;
// Minuteur "par question" : cette question est résolue (bonne réponse,
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
// "pour tout le quiz", qui doit continuer à tourner sans interruption
// d'une question à l'autre.
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
if (widget) {
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
for (let i = 0; i < buttons.length; i++) {
buttons[i].disabled = true;
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
}
}
window.forgeQuizTotalAnswered += 1;
state.answeredCount += 1;
const fx = forgeQuizTemplateEffectsFor(widget);
if (correct) {
window.forgeQuizCorrectAnswered += 1;
state.correctCount += 1;
forgeAddQuizScore(state.rewardAmount);
if (fx.onCorrect) fx.onCorrect(widget);
} else if (fx.onWrong) {
fx.onWrong(widget);
}
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
forgeXapiNotifyQuestionAnswered(
state.dialogueId,
state.objectName,
state.questionIndex,
state.questionText,
correct,
correct ? state.rewardAmount : 0,
);
}
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
forgeScorm2004NotifyQuestionAnswered(
state.dialogueId,
state.objectName,
state.questionIndex,
state.questionText,
correct,
choiceIndex,
state.correctIndex,
);
}
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
// lent ou un utilisateur de lecteur d'écran — l'avancée dépend
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
// minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox
// ci-dessus, TOUJOURS visible — demande explicite, comme la maquette
// manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
// choix juste au-dessus).
if (widget) {
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
}
}
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
// quiz-box-templates.js::onQuizComplete, ex. forgeQuizClassiqueOnQuizComplete)
// et renvoie une fonction à appeler APRÈS forgeAdvanceConversation (qui
// masque sinon le widget, voir forgeHideQuizBoxOnCurrentScreen) — null
// si ce n'est pas la fin du quiz (forceEnd=false : encore des lignes
// après) ou si le modèle actif n'a aucun écran de résultat (seul
// "classique" en a un pour l'instant, "manga"/les modèles "boîte"
// gardent leur comportement d'avant, inchangé : la boîte se masque
// simplement). `forceEnd` (true) : le minuteur du quiz ENTIER vient
// d'expirer (forgeQuizBoxTimeout) — le quiz se termine MÊME s'il
// restait des questions, jamais une simple question de position dans
// les lignes.
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
if (!widget) return null;
const dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
if (!dialogueState) return null;
const isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
if (!isEnding) return null;
const completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
if (!completeFx) return null;
const { correctCount } = state;
const { answeredCount } = state;
return function () {
widget.classList.add('is-active');
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
completeFx(widget, { correctCount, totalCount: answeredCount });
};
}
// Bouton de l'écran de résultat (voir forgeQuizPrepareResultScreen
// ci-dessus) — demande explicite : QUITTER carrément (fermer l'onglet/
// la page du navigateur), jamais recommencer le quiz. Le dialogue est
// déjà épuisé (forgeAdvanceConversation l'a fait avant d'afficher ce
// résultat) — masque quand même le widget D'ABORD (repli si la fermeture
// est refusée, voir ci-dessous), avant de tenter de fermer. "is-showing-
// result" est retirée des DEUX emplacements possibles ("manga" la pose
// sur la racine du widget, "classique" sur .classiqueContent, voir
// static/js/play/quiz-box-templates.js) — l'un des deux est toujours un
// no-op inoffensif.
//
// window.close() : un navigateur ne ferme RÉELLEMENT que les onglets
// ouverts PAR SCRIPT (window.open) ou la page auto-générée d'un
// téléchargement — jamais un onglet atteint par une navigation normale
// (tapée, cliquée, ou un lien) : restriction de sécurité du navigateur
// lui-même, aucun moyen de la contourner en JS. window.close() échoue
// alors silencieusement (aucune erreur, aucun retour) : le repli
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
// courant en pratique.
function forgeQuizResultQuit(quizObjectId) {
const widget = forgeQuizBoxWidgetById(quizObjectId);
if (widget) {
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
const content = widget.querySelector('.classiqueContent');
if (content) content.classList.remove('is-showing-result');
}
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
}
// Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer
// ci-dessus — masque la boîte à quiz et avance la conversation (SAUF à
// la toute dernière question d'un modèle qui sait afficher un écran de
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
// automatique (voir le commentaire ci-dessus).
function forgeQuizBoxContinue(quizObjectId) {
const state = forgeQuizBoxState[quizObjectId];
if (!state) return;
const widget = forgeQuizBoxWidgetById(quizObjectId);
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
if (widget && !showResultScreen) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(state.dialogueObjectId);
if (showResultScreen) showResultScreen();
}
// Minuteur expiré (demande explicite : "pouvoir paramétrer un timer pour
// répondre aux questions", jamais imposé — "aucun" reste le réglage par
// défaut) : mode "question" — cette question est comptée fausse (0
// point), la bonne réponse s'affiche, puis "Continuer →" fonctionne
// normalement. Mode "quiz" (wholeQuizExpired) — même révélation, mais
// plus AUCUN temps pour la suite : le quiz se termine tout seul après un
// court délai de lecture plutôt que d'attendre un clic. Réutilise
// EXACTEMENT le chemin d'une mauvaise réponse (forgeQuizBoxAnswer) avec
// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse
// sans en marquer une "fausse"), jamais de code dupliqué.
function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
const state = forgeQuizBoxState[quizObjectId];
if (!state || state.resolved) return;
forgeQuizBoxAnswer(quizObjectId, -1);
if (!wholeQuizExpired) return;
const widget = forgeQuizBoxWidgetById(quizObjectId);
// Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER
// est écoulé — désactivé/relibellé pour éviter toute course avec la
// fin automatique programmée juste en dessous.
if (widget) {
const continueBtn = widget.querySelector('.quizBoxContinueBtn');
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
}
setTimeout(() => {
const s = forgeQuizBoxState[quizObjectId];
if (!s) return; // déjà fermé entre-temps
// forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non
// des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT
// que forgeAdvanceConversation ne supprime forgeDialogueBoxState.
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen');
delete forgeQuizBoxState[quizObjectId];
// Force l'épuisement des lignes restantes — même sortie que la fin
// naturelle d'un dialogue (markCompleted/thenAction inclus, voir
// forgeAdvanceConversation), sans dupliquer cette logique.
const dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
if (dialogueState) {
dialogueState.index = dialogueState.lines.length;
forgeAdvanceConversation(s.dialogueObjectId);
}
if (showResultScreen) showResultScreen();
}, 1500);
}
// static/js/play/__tests__/ (node:test) — même garde que les autres
// contrôleurs de ce dossier.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeShowDialogueBox,
forgeDialogueBoxAdvance,
forgeCompleteDialogue,
forgeDialogueBoxWidgetOnCurrentScreen,
forgeDialogueBoxWidgetById,
forgeRenderDialogueBoxLine,
forgeResetDialogueBoxFooter,
forgeShowQuizBox,
forgeQuizBoxAnswer,
forgeQuizBoxContinue,
forgeQuizBoxTimeout,
forgeQuizBoxWidgetOnCurrentScreen,
forgeQuizBoxWidgetById,
forgeHideQuizBoxOnCurrentScreen,
forgeUpdateAllScoreWidgets,
forgeUpdateQuizBoxScoreDisplays,
forgeAddQuizScore,
forgeAdvanceConversation,
forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm,
forgeSyncDialogueStartedToScorm,
forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus,
forgeStartQuizTimer,
forgeStopQuizTimer,
forgeQuizTimerState,
forgeEscapeCameraForFullscreen,
forgeQuizPrepareResultScreen,
forgeQuizResultQuit,
};
}