Files
Forge-Engine/static/js/play/dialogue-box-controller.js
T
williamandClaude Sonnet 5 b5546df044
Build and deploy / test-python (push) Failing after 1m34s
Build and deploy / test-js (push) Successful in 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Quiz jouable : budget de récompense, boîte à quiz, widget score
- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme
  des points des "❓ Question" ne peut plus dépasser recompense_score
  (routes/quests/quest_dialogues.py à l'enregistrement des dialogues,
  quest_update.py si on abaisse la récompense sous ce qui est déjà
  réparti) — message d'erreur explicite (400) dans les deux sens,
  affiché via alert() côté éditeur (quest-editor.js), jamais enregistré
  silencieusement dans un état incohérent.
- Nombre de choix par question plafonné à 4 (au lieu de 8).

- Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte
  de dialogue" — screens/rendering/dialogue_box_style.py, même panneau
  "🎨 Style") :
  - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une
    question se résout au clic sur un choix, pas de "Suivant").
  - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible
    une fois posé (contrairement aux boîtes de dialogue/quiz, masquées
    par défaut).

- Moteur d'exécution (static/js/play/dialogue-box-controller.js,
  refonte) : une conversation de quête alterne maintenant répliques
  (boîte de dialogue) et questions (boîte à quiz) selon le type de
  chaque ligne. Bonne réponse -> crédite le score (compteur runtime
  dédié, jamais lié au score du moteur document) et avance ; mauvaise
  réponse -> rien, la question reste affichée pour réessayer ; aucune
  boîte à quiz posée -> la question est ignorée plutôt que de bloquer
  la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait
  des questions, toutes répondues) fait passer la quête "terminee" (en
  mémoire seulement, comme le reste de ce système — voir accepter/
  refuser une offre de quête) : le joueur peut alors quitter la scène
  normalement, ces widgets n'étant jamais des obstacles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:42:42 +02:00

282 lines
13 KiB
JavaScript

// ---------- 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 : <titre>" / 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---------- 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 = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
}
// ---------- 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(<id>)") — 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 '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
}).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 : <titre>", 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 =
'<button type="button" class="dialogueBoxNextBtn dialogueBoxAcceptBtn" onclick="forgeDialogueBoxAcceptQuest(' + quest.id + ', ' + objectId + ')">Accepter</button>' +
'<button type="button" class="dialogueBoxNextBtn dialogueBoxDeclineBtn" onclick="forgeDialogueBoxDeclineQuest(' + objectId + ')">Refuser</button>';
}
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,
};
}