From d705f58c4a7fd222e09a73269e3cddfa8260da41 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 16:08:59 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20acc=C3=A8s=20au=20canevas=20masqu=C3=A9?= =?UTF-8?q?=20+=20quiz=20:=20header=20qu=C3=AAte,=20r=C3=A9ponse=20fausse?= =?UTF-8?q?=20n'avance=20jamais=20bloqu=C3=A9e,=20anim=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 : " (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 --- screens/scenes/render_scene_object.py | 19 ++--- .../__tests__/dialogue-box-controller.test.js | 71 +++++++++++++++---- static/js/play/dialogue-box-controller.js | 69 +++++++++++++----- static/style.css | 39 ++++++++-- tests/test_dialogue_box.py | 3 +- 5 files changed, 154 insertions(+), 47 deletions(-) diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index 74f85ee9..35ef1a92 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -69,11 +69,12 @@ def render_scene_object(obj): # Widget "❓ Boîte à quiz" (voir "🖥️ Interface") — réutilise le # MÊME style que "💬 Boîte de dialogue" (resolve_dialogue_box_style/ # set_scene_object_dialogue_box_style, une seule "🎨 Style" pour - # les deux) : header = la question, corps = les choix de réponse - # (boutons remplis EN JEU par static/js/play/dialogue-box-controller.js, - # data-quiz-role sert de sélecteur) — pas de pied (une question se - # résout au clic sur un choix, pas de "Suivant"). Masqué par - # défaut au jeu, comme la boîte de dialogue. + # les deux) : header = "Quête : " (comme demandé), corps = + # la question ET ses choix de réponse ensemble (remplis EN JEU par + # static/js/play/dialogue-box-controller.js, data-quiz-role sert de + # sélecteur) — pas de pied (une question se résout au clic sur un + # choix, pas de "Suivant"). Masqué par défaut au jeu, comme la + # boîte de dialogue. style = resolve_dialogue_box_style(obj) box_style = ( f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " @@ -83,11 +84,13 @@ def render_scene_object(obj): ) return ( f'
' - f'
La question s\'affiche ici.
' - f'
' + f'
QuĂŞte : Titre de la quĂŞte
' + f'
' + f'

La question s\'affiche ici.

' + f'
' f'' f'' - f'
' + f'
' ) if obj["kind"] == "score_widget": # Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index b76269d4..15496380 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -158,18 +158,31 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet' // ---------- Enchaînement dialogue + question ("❓ Question") ---------- -function fakeQuizWidget(objectId) { - const roles = { header: { textContent: '' }, choices: { innerHTML: '' } }; +function fakeQuizChoiceBtn() { const classes = new Set(); + return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes }; +} + +function fakeQuizWidget(objectId, choiceCount) { + const roles = { header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' } }; + const classes = new Set(); + // querySelectorAll('.quizBoxChoiceBtn') n'a pas besoin de refléter le + // HTML réellement injecté dans roles.choices.innerHTML (voir + // forgeShowQuizBox) pour ces tests — juste le bon NOMBRE de boutons, + // marqués correct/wrong par forgeQuizBoxAnswer via ces mêmes objets. + const buttons = Array.from({ length: choiceCount }, () => fakeQuizChoiceBtn()); return { getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null), classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, querySelector: sel => { if (sel === '[data-quiz-role="header"]') return roles.header; + if (sel === '[data-quiz-role="question"]') return roles.question; if (sel === '[data-quiz-role="choices"]') return roles.choices; return null; }, - _roles: roles, _classes: classes, + querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []), + offsetWidth: 100, + _roles: roles, _classes: classes, _buttons: buttons, }; } @@ -188,7 +201,7 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) { test.beforeEach(() => { global.gameData.quests.push({ - id: 10, status: 'en_cours', + id: 10, title: 'Le quiz du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [ @@ -202,10 +215,10 @@ test.beforeEach(() => { window.forgeQuizScore = 0; }); -test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte de dialogue', () => { +test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête, corps = question+choix), pas la boîte de dialogue', () => { const dialogueWidget = fakeWidget(42); dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null); - const quizWidget = fakeQuizWidget(99); + const quizWidget = fakeQuizWidget(99, 2); const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { @@ -215,36 +228,66 @@ test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte d forgeDialogueBoxAdvance(42); // -> ligne "question" assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question assert.equal(quizWidget._classes.has('is-active'), true); - assert.equal(quizWidget._roles.header.textContent, '2+2 ?'); + assert.equal(quizWidget._roles.header.textContent, 'Quête : Le quiz du sorcier'); + assert.equal(quizWidget._roles.question.textContent, '2+2 ?'); assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/); assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/); } finally { global.document = previousDoc; } }); -test('mauvaise réponse au quiz : aucun effet, la question reste affichée', () => { +test('mauvaise réponse au quiz : aucun point, MAIS avance quand même (ne bloque jamais la progression), la bonne réponse est surlignée', () => { const dialogueWidget = fakeWidget(42); - const quizWidget = fakeQuizWidget(99); + const quizWidget = fakeQuizWidget(99, 2); const previousDoc = global.document; + const previousTimeout = global.setTimeout; + global.setTimeout = fn => fn(); // exécute la révélation immédiatement pour le test global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { forgeShowDialogueBox(10); forgeDialogueBoxAdvance(42); // -> question forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1) - assert.equal(quizWidget._classes.has('is-active'), true); // reste affichée assert.equal(window.forgeQuizScore, 0); // aucun point - } finally { global.document = previousDoc; } + assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), true); // le choix cliqué (faux) surligné en rouge + assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la BONNE réponse indiquée quand même + // Avance quand même à la ligne suivante — jamais bloqué sur une mauvaise réponse. + assert.equal(quizWidget._classes.has('is-active'), false); + assert.equal(dialogueWidget._classes.has('is-active'), true); + assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); + } finally { global.document = previousDoc; global.setTimeout = previousTimeout; } }); -test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la ligne suivante', () => { +test('un second clic pendant la révélation (avant l\'avance automatique) est ignoré', () => { const dialogueWidget = fakeWidget(42); - const quizWidget = fakeQuizWidget(99); + const quizWidget = fakeQuizWidget(99, 2); const previousDoc = global.document; + const previousTimeout = global.setTimeout; + const pending = []; + global.setTimeout = fn => pending.push(fn); // ne s'exécute PAS tout de suite -> la question reste affichée + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox(10); + forgeDialogueBoxAdvance(42); + forgeQuizBoxAnswer(99, 0); // 1er clic (faux) + forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré + assert.equal(window.forgeQuizScore, 0); // le 2nd clic (bon) n'a pas compté + pending.forEach(fn => fn()); + assert.equal(window.forgeQuizScore, 0); // toujours 0 après l'avance différée + } finally { global.document = previousDoc; global.setTimeout = previousTimeout; } +}); + +test('bonne réponse au quiz : crédite le score, la surligne en vert, masque la boîte, avance à la ligne suivante', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99, 2); + const previousDoc = global.document; + const previousTimeout = global.setTimeout; + global.setTimeout = fn => fn(); global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); try { forgeShowDialogueBox(10); forgeDialogueBoxAdvance(42); // -> question forgeQuizBoxAnswer(99, 1); // bonne réponse assert.equal(window.forgeQuizScore, 5); + assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); assert.equal(quizWidget._classes.has('is-active'), false); assert.equal(dialogueWidget._classes.has('is-active'), true); assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); @@ -252,7 +295,7 @@ test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee" assert.equal(forgeQuestById(10).status, 'terminee'); assert.equal(dialogueWidget._classes.has('is-active'), false); - } finally { global.document = previousDoc; } + } finally { global.document = previousDoc; global.setTimeout = previousTimeout; } }); test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => { diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index 2fd6d1b6..781ff9ec 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -121,7 +121,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) { var widget = forgeDialogueBoxWidgetById(dialogueObjectId); if (line.type === 'question') { if (widget) widget.classList.remove('is-active'); - forgeShowQuizBox(dialogueObjectId, line); + forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line); return; } forgeHideQuizBoxOnCurrentScreen(); @@ -189,43 +189,76 @@ function forgeDialogueBoxAdvance(objectId) { 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 — 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) { +// 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, + 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 = line.text; + 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 (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. +// 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) return; - if (choiceIndex !== state.correctIndex) return; - forgeAddQuizScore(state.rewardAmount); + if (!state || state.resolved) return; + state.resolved = true; var widget = forgeQuizBoxWidgetById(quizObjectId); - if (widget) widget.classList.remove('is-active'); + 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; - delete forgeQuizBoxState[quizObjectId]; - forgeAdvanceConversation(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) ---------- diff --git a/static/style.css b/static/style.css index d2a526ca..152e5c0b 100644 --- a/static/style.css +++ b/static/style.css @@ -467,7 +467,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d (.flowCanvasWrap/.animTimelineWrap ont déjà leur propre défilement), jamais sur cette zone entière — sans quoi la barre d'onglets et la barre d'outils défileraient avec le contenu au lieu de rester fixes en haut. */ -.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow:hidden; display:flex; flex-direction:column; } +.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow:hidden; display:flex; flex-direction:column; transition:margin .2s ease; } +/* Bug corrigé : les panneaux flottants (voir .floatPanel plus bas) sont + en position:fixed, DONC hors du flux flex de .builder3 — ils flottent + PAR-DESSUS le canevas sans jamais réduire sa largeur, rendant sa + partie droite (sous "⚙️ Propriétés") ou gauche (sous "🧩 Objets") + inaccessible au clic/glisser tant que le panneau correspondant reste + ouvert. Réserve maintenant leur largeur explicitement dès qu'un + panneau est ouvert (voir :not(.hidden), la classe posée/retirée par + openFloatPanel()/closeFloatPanel(), static/js/screen_edit/tree-panels.js). */ +#builder3:has(#panelLeftFloat:not(.hidden)) .builderCanvasArea{ margin-left:310px; } +#builder3:has(#propsPanelFloat:not(.hidden)) .builderCanvasArea{ margin-right:370px; } /* ---------- Panneaux gauche/droite flottants ---------- */ /* Les deux panneaux (éléments de l'écran / propriétés) sont sortis du flux @@ -670,23 +680,40 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d /* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py, static/js/play/dialogue-box-controller.js) : même squelette que - "💬 Boîte de dialogue" (header/corps, même "🎨 Style") mais pas de pied - — la question se résout au clic sur un choix. Masquée par défaut au - jeu, comme la boîte de dialogue. */ + "💬 Boîte de dialogue" (header "Quête : "/corps, même "🎨 Style") + mais pas de pied — la question se résout au clic sur un choix. Masquée + par défaut au jeu, comme la boîte de dialogue ; rejoue son animation + d'entrée à CHAQUE nouvelle question (voir forgeShowQuizBox). */ +@keyframes quizPopIn{ from{ opacity:0; transform:scale(.92) translateY(12px); } to{ opacity:1; transform:none; } } +@keyframes quizWrongShake{ 0%, 100%{ transform:translateX(0); } 20%{ transform:translateX(-6px); } 40%{ transform:translateX(6px); } 60%{ transform:translateX(-4px); } 80%{ transform:translateX(4px); } } +@keyframes quizCorrectPop{ 0%{ transform:scale(1); } 40%{ transform:scale(1.04); } 100%{ transform:scale(1); } } .quizBoxWidget{ display:flex; flex-direction:column; border-radius:10px; overflow:hidden; box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto; } .playScreen .quizBoxWidget{ display:none; } -.playScreen .quizBoxWidget.is-active{ display:flex; } +.playScreen .quizBoxWidget.is-active{ display:flex; animation:quizPopIn .3s ease; } .quizBoxHeader{ padding:8px 12px; font-weight:700; } -.quizBoxChoices{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:8px; } +.quizBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:10px; } +.quizBoxQuestionText{ margin:0; font-weight:600; } +.quizBoxChoices{ display:flex; flex-direction:column; gap:8px; } .quizBoxChoiceBtn{ background:rgba(255,255,255,.12); color:inherit; border:1px solid rgba(255,255,255,.3); border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; text-align:left; cursor:pointer; transition:background .15s ease, transform .1s ease; } .quizBoxChoiceBtn:hover{ background:rgba(255,255,255,.25); transform:translateX(2px); } +.quizBoxChoiceBtn:disabled{ cursor:default; } +.quizBoxChoiceBtn:disabled:hover{ transform:none; } +/* Révélation de la bonne/mauvaise réponse (voir forgeQuizBoxAnswer) — + toujours la bonne en vert (même si le joueur s'est trompé, demande + explicite : "indiquer la bonne réponse"), le choix cliqué en rouge s'il + ne l'était pas. */ +.quizBoxChoiceBtn.is-correct{ + background:#2ecc71 !important; border-color:#2ecc71 !important; color:#08210f; font-weight:700; + animation:quizCorrectPop .3s ease; +} +.quizBoxChoiceBtn.is-wrong{ background:#e0524d !important; border-color:#e0524d !important; animation:quizWrongShake .4s ease; } /* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/ déclencheur, contrairement aux deux widgets ci-dessus), voir diff --git a/tests/test_dialogue_box.py b/tests/test_dialogue_box.py index 718dbf61..5e621baa 100644 --- a/tests/test_dialogue_box.py +++ b/tests/test_dialogue_box.py @@ -179,7 +179,8 @@ def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup): obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box") obj = screens.get_scene_object(slug, obj_id) html = screens.render_scene_object(obj) - assert 'data-quiz-role="header"' in html + assert 'data-quiz-role="header"' in html # "Quête : ", rempli en jeu + assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps assert 'data-quiz-role="choices"' in html assert f'data-object-id="{obj_id}"' in html assert "quizBoxFooter" not in html # pas de pied, une question se résout au clic