// ---------- 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. var forgeDialogueBoxState = {}; var 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, '&').replace(//g, '>').replace(/"/g, '"'); } // ---------- 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. var forgeDialogueLineAudio = null; function forgeStopDialogueLineAudio() { if (forgeDialogueLineAudio) { forgeDialogueLineAudio.pause(); forgeDialogueLineAudio = null; } } function forgeRenderDialogueBoxLine(widget, line) { var header = widget.querySelector('[data-dialogue-role="header"]'); var 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); forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter. } } // 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) { var footer = forgeDialogueBoxFooterEl(widget); if (footer) footer.innerHTML = ''; } // ---------- Boîte à quiz ---------- function forgeQuizBoxWidgetOnCurrentScreen() { var 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; var 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() { var widget = forgeQuizBoxWidgetOnCurrentScreen(); if (!widget) return; widget.classList.remove('is-active', 'is-fullscreen'); var 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). var forgeQuizTimerState = {}; function forgeStopQuizTimer(objectId) { var 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); var state = { remaining: seconds }; forgeQuizTimerState[objectId] = state; var timerEl = widget.querySelector('[data-quiz-role="timer"]'); function render() { if (!timerEl) return; var remaining = Math.max(0, state.remaining); var m = Math.floor(remaining / 60), s = remaining % 60; timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s; timerEl.classList.toggle('is-urgent', remaining <= 5); } render(); state.intervalId = setInterval(function () { state.remaining -= 1; render(); if (state.remaining <= 0) { forgeStopQuizTimer(objectId); onExpire(); } }, 1000); } // ---------- Score ---------- function forgeUpdateAllScoreWidgets() { var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]'); for (var 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() { var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]'); for (var 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() { var total = window.forgeQuizTotalAnswered || 0; if (total === 0) return 'termine'; var percent = (window.forgeQuizCorrectAnswered / total) * 100; var threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70; return percent >= threshold ? 'reussi' : 'echoue'; } function forgeSyncAllDialoguesCompletionToScorm() { var completableIds = (gameData.completable_dialogue_ids || []); var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; }); if (!allDone) return; gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; var 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) { var state = forgeDialogueBoxState[dialogueObjectId]; if (!state) return; var line = state.lines[state.index]; var 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). var widget = forgeDialogueBoxWidgetOnCurrentScreen(); var 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: dialogueId, objectName: objectName, lines: lines, index: 0, markCompleted: !!markCompleted, thenAction: 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) { var state = forgeDialogueBoxState[dialogueObjectId]; if (!state) return; state.index += 1; if (state.index >= state.lines.length) { forgeStopDialogueLineAudio(); forgeHideQuizBoxOnCurrentScreen(); var 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()") — sans // effet pendant une question en cours (le "Suivant" n'existe pas alors, // voir forgeShowCurrentConversationLine, mais gardé strict par sécurité). function forgeDialogueBoxAdvance(objectId) { var 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) { var widget = forgeQuizBoxWidgetOnCurrentScreen(); if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; } var quizObjectId = widget.getAttribute('data-object-id'); var previousState = forgeQuizBoxState[quizObjectId]; forgeQuizBoxState[quizObjectId] = { dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false, dialogueId: dialogueId, objectName: objectName, questionIndex: 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, }; var header = widget.querySelector('[data-quiz-role="header"]'); var questionEl = widget.querySelector('[data-quiz-role="question"]'); var 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(function (choice, i) { return ''; }).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 += ''; } // "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. var 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). var timerMode = widget.getAttribute('data-timer-mode'); var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0; if (timerMode === 'question') { forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); }); } else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) { forgeQuizBoxState[quizObjectId].quizTimerStarted = true; forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { 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. var 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) { var 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) { var state = forgeQuizBoxState[quizObjectId]; if (!state || state.resolved) return; state.resolved = true; var widget = forgeQuizBoxWidgetById(quizObjectId); var 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) { var buttons = widget.querySelectorAll('.quizBoxChoiceBtn'); for (var 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; var 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) { var 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; var dialogueState = forgeDialogueBoxState[state.dialogueObjectId]; if (!dialogueState) return null; var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length); if (!isEnding) return null; var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete; if (!completeFx) return null; var correctCount = state.correctCount; var answeredCount = state.answeredCount; return function () { widget.classList.add('is-active'); if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen'); completeFx(widget, { correctCount: 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) { var widget = forgeQuizBoxWidgetById(quizObjectId); if (widget) { widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result'); var 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) { var state = forgeQuizBoxState[quizObjectId]; if (!state) return; var widget = forgeQuizBoxWidgetById(quizObjectId); var 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) { var state = forgeQuizBoxState[quizObjectId]; if (!state || state.resolved) return; forgeQuizBoxAnswer(quizObjectId, -1); if (!wholeQuizExpired) return; var 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) { var continueBtn = widget.querySelector('.quizBoxContinueBtn'); if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; } } setTimeout(function () { var 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. var 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. var 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, }; }