// ---------- Widgets "💬 BoĂźte de dialogue" / "❓ BoĂźte Ă  quiz" / "🏆 Score" ---------- // Fait le lien entre l'Ă©diteur de collision (action "quete", 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 // "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la // quĂȘte (gameData.quests, voir full_game_payload.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/quests/quest-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). // // Une fois le dialogue "nouvelle" Ă©puisĂ© : la boĂźte bascule en Ă©cran // d'OFFRE ("QuĂȘte : " / objectif / boutons Accepter-Refuser) au // lieu de se masquer — accepter fait passer la quĂȘte "en_cours" ; refuser // ferme simplement la boĂźte SANS rien changer (la quĂȘte reste "nouvelle", // la prochaine interaction rejoue exactement le mĂȘme dialogue). Une fois // le dialogue "en_cours" Ă©puisĂ© (donc, s'il contenait des questions, // toutes rĂ©pondues correctement), la quĂȘte passe "terminee" — le joueur // peut alors quitter la scĂšne normalement (aucun blocage, ces widgets ne // sont jamais des obstacles). Tout ceci reste LOCAL Ă  la partie en cours // (gameData.quests n'est qu'un instantanĂ© par joueur, voir // full_game_payload.py) : jamais persistĂ© en base, qui garde le statut // de DÉPART pour toute nouvelle partie. // // Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dĂ©diĂ© // Ă  ce systĂšme de quĂȘte/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 la quĂȘte est introuvable ou si l'auteur n'a // posĂ© aucun widget requis sur l'Ă©cran — mĂȘme esprit que le reste du moteur. var forgeDialogueBoxState = {}; var forgeQuizBoxState = {}; window.forgeQuizScore = window.forgeQuizScore || 0; function forgeQuestById(questId) { return (gameData.quests || []).find(function (q) { return q.id === questId; }); } 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) — nĂ©cessaire pour effacer // les boutons Accepter/Refuser d'une offre PRÉCÉDENTE avant de rejouer // un nouveau dialogue dans la mĂȘme boĂźte. 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 quĂȘtes 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 quĂȘtes/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 // quĂȘte/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); } // Une quĂȘte acceptĂ©e = 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. function forgeSyncQuestStartedToScorm() { 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 TOUTES les quĂȘtes connues // sont "terminee" — rĂšgle simple et prĂ©visible plutĂŽt que de deviner // laquelle des quĂȘtes créées "compte" pour la fin du jeu. function forgeSyncAllQuestsCompletionToScorm() { var quests = gameData.quests || []; if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return; gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; gameData.scoring.status = 'termine'; if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged('termine'); } 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, forgeQuestById(state.questId), line); return; } forgeHideQuizBoxOnCurrentScreen(); if (!widget) return; forgeResetDialogueBoxFooter(widget, dialogueObjectId); forgeRenderDialogueBoxLine(widget, line); widget.classList.add('is-active'); } // AppelĂ©e par l'action "quete" (collision-rules-controller.js) — trouve // le widget posĂ© sur l'Ă©cran ACTUELLEMENT affichĂ© (un seul Ă  la fois, // comme le reste du moteur 2D). function forgeShowDialogueBox(questId) { var quest = forgeQuestById(questId); if (!quest) return; var lines = (quest.dialogues && quest.dialogues[quest.status]) || []; var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); // Aucune ligne posĂ©e pour ce statut : une quĂȘte "nouvelle" propose // quand mĂȘme directement l'offre (accepter/refuser), les autres // statuts restent silencieux (rien Ă  montrer). if (!lines.length) { if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest); return; } forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 }; forgeShowCurrentConversationLine(objectId); } // 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). function forgeAdvanceConversation(dialogueObjectId) { var state = forgeDialogueBoxState[dialogueObjectId]; if (!state) return; state.index += 1; if (state.index >= state.lines.length) { forgeHideQuizBoxOnCurrentScreen(); var widget = forgeDialogueBoxWidgetById(dialogueObjectId); var quest = forgeQuestById(state.questId); if (quest && quest.status === 'nouvelle') { if (widget) forgeShowQuestOffer(widget, dialogueObjectId, quest); return; } // Tout le dialogue "en_cours" Ă©puisĂ© (donc, s'il contenait des // questions, toutes rĂ©pondues correctement) -> quĂȘte terminĂ©e. Voir // demande utilisateur : "quand le joueur a rĂ©pondu Ă  toutes les // questions la quĂȘte passe au statut terminĂ©". if (quest && quest.status === 'en_cours') { quest.status = 'terminee'; forgeSyncAllQuestsCompletionToScorm(); } 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); } // Combien de temps la bonne/mauvaise rĂ©ponse reste affichĂ©e (surlignĂ©e, // voir forgeQuizBoxAnswer) avant d'avancer automatiquement Ă  la ligne // suivante — assez long pour LIRE la bonne rĂ©ponse en cas d'erreur, plus // court si la rĂ©ponse Ă©tait dĂ©jĂ  correcte (voir static/style.css, // @keyframes quizPopIn/quizWrongShake pour l'animation elle-mĂȘme). var FORGE_QUIZ_REVEAL_DELAY_CORRECT = 700; var FORGE_QUIZ_REVEAL_DELAY_WRONG = 1400; // Affiche une question ("❓ Question") dans la boĂźte Ă  quiz posĂ©e sur // l'Ă©cran — header = "QuĂȘte : ", corps = la question + ses choix // (voir demande utilisateur) — 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, quest, line) { 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, }; 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 = 'QuĂȘte : ' + (quest ? quest.title : ''); 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'); } } if (correct) forgeAddQuizScore(state.rewardAmount); var dialogueObjectId = state.dialogueObjectId; setTimeout(function () { if (widget) widget.classList.remove('is-active'); delete forgeQuizBoxState[quizObjectId]; forgeAdvanceConversation(dialogueObjectId); }, correct ? FORGE_QUIZ_REVEAL_DELAY_CORRECT : FORGE_QUIZ_REVEAL_DELAY_WRONG); } // ---------- Offre de quĂȘte (Accepter/Refuser) ---------- // Écran d'offre : header = "QuĂȘte : ", body = l'objectif, pied = // Accepter/Refuser au lieu du "Suivant" habituel. function forgeShowQuestOffer(widget, objectId, quest) { var header = widget.querySelector('[data-dialogue-role="header"]'); var body = widget.querySelector('[data-dialogue-role="body"]'); var footer = forgeDialogueBoxFooterEl(widget); if (header) header.textContent = 'QuĂȘte : ' + quest.title; if (body) body.textContent = quest.objectif || ''; if (footer) { footer.innerHTML = '' + ''; } forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 }; widget.classList.add('is-active'); } // Fait passer la quĂȘte "en_cours" — UNIQUEMENT dans l'instantanĂ© en // mĂ©moire (gameData.quests), jamais persistĂ© en base : voir la note en // tĂȘte de fichier (le statut en base est celui de DÉPART pour toute // nouvelle partie, pas un Ă©tat de partie en cours). function forgeDialogueBoxAcceptQuest(questId, objectId) { var quest = forgeQuestById(questId); if (quest) quest.status = 'en_cours'; forgeSyncQuestStartedToScorm(); var widget = forgeDialogueBoxWidgetById(objectId); if (widget) widget.classList.remove('is-active'); delete forgeDialogueBoxState[objectId]; } // Referme la boĂźte SANS toucher au statut de la quĂȘte (reste "nouvelle") // — la prochaine interaction rejoue donc le mĂȘme dialogue depuis le // dĂ©but : impossible d'avancer la quĂȘte sans un jour l'accepter. function forgeDialogueBoxDeclineQuest(objectId) { var widget = forgeDialogueBoxWidgetById(objectId); if (widget) widget.classList.remove('is-active'); delete forgeDialogueBoxState[objectId]; } // 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 = { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm, }; }