// ---------- 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"]'); } 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; } // 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() { return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget'); } function forgeQuizBoxWidgetById(objectId) { return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]'); } function forgeHideQuizBoxOnCurrentScreen() { var widget = forgeQuizBoxWidgetOnCurrentScreen(); if (widget) widget.classList.remove('is-active'); } // ---------- 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; } // 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 || []; var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); 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[objectId] = { dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0, markCompleted: !!markCompleted, thenAction: thenAction, }; forgeShowCurrentConversationLine(objectId); } // 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) { 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'); forgeQuizBoxState[quizObjectId] = { dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false, dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text, }; 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(''); } // 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'); } // 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; 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; if (correct) { window.forgeQuizCorrectAnswered += 1; forgeAddQuizScore(state.rewardAmount); } 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 est ajouté APRÈS les choix (jamais en remplaçant // choicesEl.innerHTML, qui effacerait le surlignage vert/rouge déjà posé). if (widget) { var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); if (choicesEl) { choicesEl.innerHTML += ''; } } } // Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer // ci-dessus — masque la boîte à quiz et avance la conversation, jamais // automatique (voir le commentaire ci-dessus). function forgeQuizBoxContinue(quizObjectId) { var state = forgeQuizBoxState[quizObjectId]; if (!state) return; var widget = forgeQuizBoxWidgetById(quizObjectId); if (widget) widget.classList.remove('is-active'); delete forgeQuizBoxState[quizObjectId]; forgeAdvanceConversation(state.dialogueObjectId); } // 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, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm, forgeComputeFinalScormStatus, }; }