- Bug corrigé : les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" sont en position:fixed (hors du flux flex de .builder3) — ils flottaient PAR-DESSUS le canevas sans jamais réduire sa largeur, rendant sa partie droite/gauche inaccessible au clic/glisser tant qu'un panneau restait ouvert. .builderCanvasArea réserve maintenant leur largeur (marge) dès qu'un panneau est ouvert (:has()). - Boîte à quiz : header = "Quête : <titre>" (au lieu du texte de la question), corps = la question ET ses choix ensemble. - Mauvaise réponse : ne bloque plus JAMAIS la progression (bug signalé : "je suis obligé de bien répondre sinon j'avance pas") — surligne la bonne réponse en vert (le choix cliqué en rouge s'il était faux) puis avance automatiquement après un court délai, sans jamais octroyer de points. Un second clic pendant la révélation est ignoré. - Animations : la boîte à quiz rejoue une entrée (pop-in) à CHAQUE nouvelle question, la bonne réponse pulse en vert, une mauvaise réponse "secoue" en rouge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
315 lines
15 KiB
JavaScript
315 lines
15 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, '&').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 = '<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, 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';
|
|
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);
|
|
}
|
|
|
|
// 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 : <titre>", 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 '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
|
}).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 : <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,
|
|
};
|
|
}
|