// ---------- 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; } function forgeAddQuizScore(amount) { window.forgeQuizScore += (amount || 0); forgeUpdateAllScoreWidgets(); } // ---------- 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, 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'; 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 — si l'auteur n'en a posé aucune, 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, 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, }; var header = widget.querySelector('[data-quiz-role="header"]'); var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); if (header) header.textContent = line.text; if (choicesEl) { choicesEl.innerHTML = line.choices.map(function (choice, i) { return ''; }).join(''); } widget.classList.add('is-active'); } // Appelée par le clic sur un choix (voir render_scene_object.py côté // éditeur — les vrais choix sont injectés EN JEU par forgeShowQuizBox // ci-dessus, jamais rendus côté serveur). Mauvaise réponse : rien, la // question reste affichée. Bonne réponse : crédite le score, masque la // boîte, avance la conversation. function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { var state = forgeQuizBoxState[quizObjectId]; if (!state) return; if (choiceIndex !== state.correctIndex) return; forgeAddQuizScore(state.rewardAmount); var widget = forgeQuizBoxWidgetById(quizObjectId); if (widget) widget.classList.remove('is-active'); var dialogueObjectId = state.dialogueObjectId; delete forgeQuizBoxState[quizObjectId]; forgeAdvanceConversation(dialogueObjectId); } // ---------- 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'; 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, }; }