// ---------- 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() {
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) {
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');
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,
};
}