diff --git a/static/js/play/__tests__/collision-quete-dialogue-integration.test.js b/static/js/play/__tests__/collision-quete-dialogue-integration.test.js index dc0ce522..2cec7214 100644 --- a/static/js/play/__tests__/collision-quete-dialogue-integration.test.js +++ b/static/js/play/__tests__/collision-quete-dialogue-integration.test.js @@ -19,7 +19,10 @@ Object.assign(global, actionsMod); require('../personnage-controller.js'); const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier -const { forgeCollisionRuleTick } = require('../collision-rules-controller.js'); +const { + forgeCollisionRuleTick, +} = require('../collision-rules-controller.js'); +const { forgeDialogueBoxAcceptQuest, forgeQuestById } = require('../dialogue-box-controller.js'); function fakeEl(style) { const classes = new Set(); @@ -33,13 +36,14 @@ function fakeEl(style) { if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget); if (sel === '[data-dialogue-role="header"]') return this._header; if (sel === '[data-dialogue-role="body"]') return this._body; + if (sel === '[data-dialogue-role="footer"]') return this._footer; return undefined; }, getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; }, }; } -test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suivant" avance, se masque à la fin', () => { +test('collision "quete" -> dialogue puis offre de quête -> Accepter fait passer la quête en_cours', () => { const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' }; const mrPinguin = { id: 2, kind: 'personnage', @@ -54,12 +58,13 @@ test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suiva boxWidget._objectId = '3'; boxWidget._header = { textContent: '' }; boxWidget._body = { textContent: '' }; + boxWidget._footer = { innerHTML: '' }; global.window.currentScreenId = 1; global.gameData = { personnage_animations: {}, quests: [{ - id: 99, status: 'nouvelle', + id: 99, title: 'Retrouver Charlie', status: 'nouvelle', objectif: 'Parler à Mr. Pinguin', dialogues: { nouvelle: [ { speaker: 'Mr. Pinguin', text: 'Bonjour !' }, @@ -102,7 +107,14 @@ test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suiva assert.equal(boxWidget._body.textContent, 'Salut !'); assert.equal(boxWidget.classList.contains('is-active'), true); - forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> la modale disparaît - assert.equal(boxWidget.classList.contains('is-active'), false); + forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> quête "nouvelle" -> écran d'offre, PAS masqué + assert.equal(boxWidget.classList.contains('is-active'), true); + assert.equal(boxWidget._header.textContent, 'Quête : Retrouver Charlie'); + assert.equal(boxWidget._body.textContent, 'Parler à Mr. Pinguin'); + assert.match(boxWidget._footer.innerHTML, /Accepter/); + + forgeDialogueBoxAcceptQuest(99, '3'); // clic "Accepter" + assert.equal(forgeQuestById(99).status, 'en_cours'); + assert.equal(boxWidget.classList.contains('is-active'), false); // la modale disparaît } finally { global.document = previousDoc; } }); diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index ef494c94..c9b899d0 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -10,12 +10,14 @@ global.gameData = {}; const { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, + forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, } = require('../dialogue-box-controller.js'); function fakeWidget(objectId) { const roles = { header: { textContent: '' }, body: { textContent: '' }, + footer: { innerHTML: '' }, }; const classes = new Set(); return { @@ -24,6 +26,7 @@ function fakeWidget(objectId) { querySelector: sel => { if (sel === '[data-dialogue-role="header"]') return roles.header; if (sel === '[data-dialogue-role="body"]') return roles.body; + if (sel === '[data-dialogue-role="footer"]') return roles.footer; return null; }, _roles: roles, _classes: classes, @@ -34,8 +37,9 @@ test.beforeEach(() => { global.window.currentScreenId = 1; global.gameData = { quests: [ - { id: 5, status: 'nouvelle', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } }, + { id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } }, { id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } }, + { id: 8, status: 'terminee', dialogues: { nouvelle: [], en_cours: [], terminee: [{ speaker: 'PNJ', text: 'Merci !' }] } }, ], }; }); @@ -53,11 +57,28 @@ test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage } finally { global.document = previousDoc; } }); -test('forgeShowDialogueBox — quête sans réplique pour son statut actuel : aucun effet', () => { +test('forgeShowDialogueBox — quête sans réplique pour son statut "en_cours" (pas "nouvelle") : aucun effet', () => { + const widget = fakeWidget(42); const previousDoc = global.document; - global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } }; + global.document = { querySelector: () => widget }; try { - assert.doesNotThrow(() => forgeShowDialogueBox(6)); // "en_cours" -> dialogues.en_cours est vide + forgeShowDialogueBox(6); // "en_cours" -> dialogues.en_cours est vide, et statut != "nouvelle" + assert.equal(widget._classes.has('is-active'), false); + } finally { global.document = previousDoc; } +}); + +test('forgeShowDialogueBox — quête "nouvelle" SANS réplique : propose directement l\'offre (accepter/refuser)', () => { + global.gameData.quests.push({ id: 7, title: 'Le trésor perdu', status: 'nouvelle', objectif: 'Trouver le trésor', dialogues: {} }); + const widget = fakeWidget(42); + const previousDoc = global.document; + global.document = { querySelector: () => widget }; + try { + forgeShowDialogueBox(7); + assert.equal(widget._roles.header.textContent, 'Quête : Le trésor perdu'); + assert.equal(widget._roles.body.textContent, 'Trouver le trésor'); + assert.match(widget._roles.footer.innerHTML, /Accepter/); + assert.match(widget._roles.footer.innerHTML, /Refuser/); + assert.equal(widget._classes.has('is-active'), true); } finally { global.document = previousDoc; } }); @@ -69,22 +90,59 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet', } finally { global.document = previousDoc; } }); -test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => { +test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque (quête pas "nouvelle")', () => { + const widget = fakeWidget(42); + const previousDoc = global.document; + global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; + try { + forgeShowDialogueBox(8); // statut "terminee" -> pas d'offre à la fin, se masque normalement + assert.equal(widget._roles.header.textContent, 'PNJ'); + assert.equal(widget._roles.body.textContent, 'Merci !'); + assert.equal(widget._classes.has('is-active'), true); + + forgeDialogueBoxAdvance(42); // dernière (et unique) réplique consommée -> se masque + assert.equal(widget._classes.has('is-active'), false); + } finally { global.document = previousDoc; } +}); + +test('quête "nouvelle" : le dialogue épuisé bascule sur l\'offre (Accepter/Refuser) au lieu de se masquer', () => { const widget = fakeWidget(42); const previousDoc = global.document; global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; try { forgeShowDialogueBox(5); - assert.equal(widget._roles.header.textContent, 'Le Sorcier'); - assert.equal(widget._roles.body.textContent, 'Bonjour !'); - assert.equal(widget._classes.has('is-active'), true); - forgeDialogueBoxAdvance(42); - assert.equal(widget._roles.header.textContent, 'Joueur'); - assert.equal(widget._roles.body.textContent, 'Salut.'); - assert.equal(widget._classes.has('is-active'), true); // encore une réplique visible + forgeDialogueBoxAdvance(42); // dernière réplique consommée -> écran d'offre, PAS masqué + assert.equal(widget._classes.has('is-active'), true); + assert.equal(widget._roles.header.textContent, 'Quête : La quête du sorcier'); + assert.equal(widget._roles.body.textContent, 'Parler au sorcier'); + assert.match(widget._roles.footer.innerHTML, /Accepter/); + assert.match(widget._roles.footer.innerHTML, /Refuser/); + } finally { global.document = previousDoc; } +}); - forgeDialogueBoxAdvance(42); // dernière réplique consommée -> se masque +test('forgeDialogueBoxAcceptQuest — fait passer la quête "en_cours" (en mémoire seulement) et masque la boîte', () => { + const widget = fakeWidget(42); + const previousDoc = global.document; + global.document = { querySelector: () => widget }; + try { + forgeShowDialogueBox(5); + widget._classes.add('is-active'); + forgeDialogueBoxAcceptQuest(5, 42); + assert.equal(forgeQuestById(5).status, 'en_cours'); + assert.equal(widget._classes.has('is-active'), false); + } finally { global.document = previousDoc; } +}); + +test('forgeDialogueBoxDeclineQuest — masque la boîte SANS changer le statut (reste "nouvelle")', () => { + const widget = fakeWidget(42); + const previousDoc = global.document; + global.document = { querySelector: () => widget }; + try { + forgeShowDialogueBox(5); + widget._classes.add('is-active'); + forgeDialogueBoxDeclineQuest(42); + assert.equal(forgeQuestById(5).status, 'nouvelle'); // inchangé -> le dialogue rejouera depuis le début assert.equal(widget._classes.has('is-active'), false); } finally { global.document = previousDoc; } }); diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index 23e49fb2..79732c14 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -5,10 +5,21 @@ // screens/rendering/dialogue_box_style.py) : à l'exécution d'une action // "quete", affiche la réplique en cours du dialogue correspondant au // STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py), -// avance d'une réplique à chaque clic sur "Suivant", se masque à la fin. -// Silencieux (aucun effet) si la quête est introuvable, n'a aucune -// réplique pour son statut, ou si l'auteur n'a posé aucun widget sur -// l'écran — même esprit que le reste du moteur (jamais planter). +// avance d'une réplique à chaque clic sur "Suivant". +// +// Une fois le dialogue épuisé : si la quête est encore "nouvelle", 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" (LOCAL à cette partie, jamais persisté en base : +// gameData.quests n'est qu'un instantané par joueur, voir +// full_game_payload.py ; muter la ligne en base changerait le statut de +// DÉPART de la quête pour toutes les parties futures) ; refuser ferme +// simplement la boîte SANS changer le statut — la quête reste "nouvelle", +// donc la prochaine interaction rejoue EXACTEMENT le même dialogue depuis +// le début (impossible d'avancer sans accepter). Pour tout autre statut +// (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin. +// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a +// posé aucun widget sur l'écran — même esprit que le reste du moteur. var forgeDialogueBoxState = {}; @@ -24,6 +35,10 @@ 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"]'); @@ -31,6 +46,15 @@ function forgeRenderDialogueBoxLine(widget, line) { 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 = ''; +} + // 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). @@ -38,11 +62,18 @@ function forgeShowDialogueBox(questId) { var quest = forgeQuestById(questId); if (!quest) return; var lines = (quest.dialogues && quest.dialogues[quest.status]) || []; - if (!lines.length) return; var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); - forgeDialogueBoxState[objectId] = { lines: lines, index: 0 }; + // Aucune réplique 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; + } + forgeResetDialogueBoxFooter(widget, objectId); + forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 }; forgeRenderDialogueBoxLine(widget, lines[0]); widget.classList.add('is-active'); } @@ -55,6 +86,11 @@ function forgeDialogueBoxAdvance(objectId) { if (!state || !widget) return; state.index += 1; if (state.index >= state.lines.length) { + var quest = forgeQuestById(state.questId); + if (quest && quest.status === 'nouvelle') { + forgeShowQuestOffer(widget, objectId, quest); + return; + } widget.classList.remove('is-active'); delete forgeDialogueBoxState[objectId]; return; @@ -62,11 +98,50 @@ function forgeDialogueBoxAdvance(objectId) { forgeRenderDialogueBoxLine(widget, state.lines[state.index]); } +// É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, }; } diff --git a/static/style.css b/static/style.css index 8873f991..151257ac 100644 --- a/static/style.css +++ b/static/style.css @@ -653,12 +653,20 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .playScreen .dialogueBoxWidget.is-active{ display:flex; } .dialogueBoxHeader{ padding:8px 12px; font-weight:700; } .dialogueBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; } -.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; } +.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; gap:8px; } .dialogueBoxNextBtn{ background:rgba(255,255,255,.15); color:inherit; border:1px solid rgba(255,255,255,.3); border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer; } .dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); } +/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) : + Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la + boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur + de pied choisie par l'auteur (voir "🎨 Style"). */ +.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; } +.dialogueBoxAcceptBtn:hover{ background:#3ddb80; } +.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; } +.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); } /* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur sa propre balise (voir applySelectionHighlight) — seul moyen de la voir