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