// ---------- Éditeur de quêtes ---------- // Onglet "🗺️ Quêtes" à côté de "🧩 Collision" (templates/scene_edit.html, // jamais une page à part) : une LISTE (mêmes tableau/lignes que l'onglet // "📣 Événements") plutôt qu'une grille de cartes. Un clic sur une ligne // ouvre la modale de FICHE (titre/objectif/récompense/statut/résultat — // "je crée"), qui ouvre à son tour, sur un bouton dédié, la modale // PLEIN ÉCRAN d'arbre de dialogue ("j'ajoute les dialogues") — deux // étapes séparées, jamais le même formulaire. Les deux modales sont // aussi ouvertes depuis l'assistant "+ Action" de l'éditeur de collision // (voir static/js/scenes/collision-rules-editor.js) quand l'auteur // choisit "Déclencher une quête" — créées dynamiquement (ensureQuest*Modal) // plutôt que dupliquées dans deux endroits. // // Une quête (voir db/quests/) : id, title, objectif, recompense_score, // status (nouvelle/en_cours/terminee — statut COURANT, informatif), // resultat (reussi/echoue), dialogues (une liste de répliques PAR // STATUT — 3 colonnes dans la modale : quel dialogue jouer selon l'état // de la quête au moment où le joueur parle au PNJ). var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' }; var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' }; var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; // Quêtes connues côté client — préremplies par QUESTS_JSON (voir // templates/scene_edit.html), toujours présentes puisque l'onglet // Quêtes vit dans le même éditeur que l'onglet Collision qui en a // besoin (collisionRuleActionSummary). var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; // Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/ // scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas // seulement un personnage — proposés comme "qui parle" pour une réplique, // en plus de "Joueur" (toujours disponible, jamais un objet de scène). var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : []; // Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/ // .questBubble-pnj, static/style.css) même si le nom affiché est // n'importe quel objet nommé — "Joueur" en vert, n'importe // quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles // du pnj et du joueur sont d'une couleur différente"). function questBubbleKind(speaker) { return speaker === 'Joueur' ? 'joueur' : 'pnj'; } function questById(questId) { return QUESTS.find(function (q) { return q.id === questId; }); } function questListRowHtml(quest) { var resultatHtml = quest.resultat ? '' + QUEST_RESULT_LABELS[quest.resultat] + '' : '—'; return ( '' + '' + (quest.title || '').replace(/' + '' + (quest.objectif ? quest.objectif.replace(/—') + '' + '🏆 +' + (quest.recompense_score || 0) + '' + '' + QUEST_STATUS_LABELS[quest.status] + '' + '' + resultatHtml + '' + '' + '' + '' + '' ); } function renderQuestListPage() { var container = document.getElementById('questsTabPanel'); if (!container) return; var wrap = document.getElementById('questsListWrap'); if (!QUESTS.length) { wrap.innerHTML = '

Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.

'; return; } wrap.innerHTML = '
' + '' + '' + QUESTS.map(questListRowHtml).join('') + '' + '
TitreObjectifRécompenseStatutRésultat
'; } function createQuestFromListPage() { fetch('/game/' + GAME_SLUG + '/quests/new', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) .then(function (r) { return r.json(); }) .then(function (json) { QUESTS.unshift(json.quest); renderQuestListPage(); openQuestFieldsModal(json.quest.id); }); } function questDeleteFromList(questId) { var quest = questById(questId); if (!quest || !confirm('Supprimer la quête « ' + quest.title + ' » ? Toute règle de collision qui la référence perdra sa cible.')) return; fetch('/game/' + GAME_SLUG + '/quests/' + questId + '/delete', { method: 'POST' }) .then(function () { QUESTS = QUESTS.filter(function (q) { return q.id !== questId; }); renderQuestListPage(); }); } // ---------- Deux modales distinctes ---------- // 1. #questFieldsModal (taille normale) : "je crée" — titre/objectif/ // récompense/statut/résultat, + un bouton qui ouvre... // 2. #questDialogueModal (PLEIN ÉCRAN) : "j'ajoute les dialogues" — les // 3 colonnes de répliques, rien d'autre. Étapes séparées demandées // explicitement par l'utilisateur (pas un seul gros formulaire). // Créées une seule fois chacune (au premier appel), réutilisées ensuite — // partagées avec l'assistant "+ Action" de l'éditeur de collision, // jamais dupliquées dans les templates. var _questModalOnUse = null; var _questModalCurrentId = null; function ensureQuestFieldsModal() { var modal = document.getElementById('questFieldsModal'); if (modal) return modal; modal = document.createElement('div'); modal.className = 'modal'; modal.id = 'questFieldsModal'; modal.innerHTML = '' + ''; document.body.appendChild(modal); return modal; } // `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...) // pour finaliser la règle de collision avec cette quête, affiche alors un // bouton "✓ Utiliser cette quête" dans les deux modales. function openQuestFieldsModal(questId, onUse) { _questModalOnUse = onUse || null; _questModalCurrentId = questId; var modal = ensureQuestFieldsModal(); modal.classList.add('is-active'); renderQuestFieldsModalBody(questId); } // _questModalOnUse/_questModalCurrentId ne sont PAS remis à null ici : // openQuestDialogueModal() ferme cette modale en s'ouvrant (voir plus // bas) et a besoin de les garder pour son propre bouton "✓ Utiliser // cette quête" — chaque point d'entrée (openQuestFieldsModal) les fixe // de toute façon fraîchement à chaque ouverture. function closeQuestFieldsModal() { var modal = document.getElementById('questFieldsModal'); if (modal) modal.classList.remove('is-active'); } function questUseThisQuest(questId) { var quest = questById(questId); if (_questModalOnUse && quest) _questModalOnUse(quest); closeQuestDialogueModal(); closeQuestFieldsModal(); _questModalOnUse = null; _questModalCurrentId = null; } function questFieldRowHtml(quest) { return ( '
' + '" + '" + '" + '' + '' + '
' ); } function renderQuestFieldsModalBody(questId) { var quest = questById(questId); var body = document.getElementById('questFieldsModalBody'); if (!quest || !body) return; var useBtn = _questModalOnUse ? '' : ''; body.innerHTML = '
' + '

🗺️ ' + (quest.title || '').replace(/' + '' + '

' + questFieldRowHtml(quest) + '
' + '' + useBtn + '
'; } // Options du ' + speakerSelect + '' + '' + '' + '
' + '' + '
' + '' + '' ); } function questEscAttr(str) { return (str || '').replace(/"/g, '"'); } // "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de // dialogue (même colonne, même alignement vertical relié par un trait, // voir .questBubbleRow/.questBubble ci-dessus et static/style.css) mais // un type de ligne différent (voir db/quests/sanitize_quest_dialogues.py) : // header = nombre de choix + récompense (type + montant), body = la // question et ses choix, avec la bonne réponse cochée. Aucune limite au // nombre de questions par colonne (voir questAddQuestion), comme pour // une réplique. function questQuestionChoiceRowHtml(choice, choiceIndex, correctIndex, status, index) { var radioName = 'questCorrect-' + status + '-' + index; return ( '
' + '' + '' + '
' ); } function questQuestionBubbleHtml(line, status, index) { var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join(''); return ( '
' + '
' + '
' + '' + '' + '' + '
' + '
' + '' + '
' + choicesHtml + '
' + '
' + '
' + '
' ); } function questDialogueColumnHtml(quest, status) { var lines = (quest.dialogues && quest.dialogues[status]) || []; var bubblesHtml = lines.map(function (line, i) { return line.type === 'question' ? questQuestionBubbleHtml(line, status, i) : questDialogueBubbleHtml(line, status, i); }).join(''); return ( '
' + '

' + QUEST_STATUS_LABELS[status] + '

' + '
' + bubblesHtml + '
' + '
' + '' + '' + '
' + '
' ); } // Modale de dialogue — PLEIN ÉCRAN (voir .questDialogueModal en CSS), // ouverte DEPUIS la modale de fiche (bouton "💬 Ajouter les dialogues"), // jamais les deux à la fois : referme la fiche en s'ouvrant plutôt que de // les empiler, "← Fiche de la quête" y ramène. function ensureQuestDialogueModal() { var modal = document.getElementById('questDialogueModal'); if (modal) return modal; modal = document.createElement('div'); modal.className = 'modal questDialogueModal'; modal.id = 'questDialogueModal'; modal.innerHTML = '' + ''; document.body.appendChild(modal); return modal; } function openQuestDialogueModal(questId) { _questModalCurrentId = questId; closeQuestFieldsModal(); var modal = ensureQuestDialogueModal(); modal.classList.add('is-active'); renderQuestDialogueModalBody(questId); } function closeQuestDialogueModal() { var modal = document.getElementById('questDialogueModal'); if (modal) modal.classList.remove('is-active'); } function questBackToFieldsModal(questId) { closeQuestDialogueModal(); openQuestFieldsModal(questId, _questModalOnUse); } function renderQuestDialogueModalBody(questId) { var quest = questById(questId); var body = document.getElementById('questDialogueModalBody'); if (!quest || !body) return; _questModalCurrentId = questId; var useBtn = _questModalOnUse ? '' : ''; body.innerHTML = '
' + '
' + '' + '

🗺️ ' + (quest.title || '').replace(/' + '

' + '
' + useBtn + '' + '
' + '
' + '

Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :

' + '
' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '
'; } function questUpdateField(questId, field, value) { var quest = questById(questId); if (!quest) return; var body = {}; body[field] = field === 'recompense_score' ? Number(value) : (value || null); fetch('/game/' + GAME_SLUG + '/quests/' + questId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); }) .then(function (res) { // Refusé (400) : recompense_score abaissé sous le total déjà // réparti entre les questions (voir routes/quests/quest_update.py) // — message d'erreur demandé explicitement, et le champ est // remis à sa valeur RÉELLE (inchangée côté serveur). if (!res.ok) { alert(res.json.error || "Impossible d'enregistrer."); renderQuestFieldsModalBody(questId); return; } if (!res.json.quest) return; Object.assign(quest, res.json.quest); renderQuestListPage(); renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables }); } // Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir // db/quests/set_quest_dialogues.py) — jamais une réplique isolée. // N'ADOPTE JAMAIS la réponse du serveur comme nouvel état local (bug // corrigé : sanitize_quest_dialogues.py rejette toute réplique au texte // encore vide — juste ajoutée, pas encore tapée — donc chaque // enregistrement renvoyait un tableau amputé de la bulle en cours // d'écriture ; l'adopter effaçait silencieusement la bulle que l'auteur // venait d'ajouter, et le clic suivant sur "+ Réplique" semblait la // "remplacer" au lieu d'en ajouter une seconde). L'état local, construit // par des mutations client explicites, est déjà la seule source de // vérité pendant l'édition. function questSaveDialogues(quest) { fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }), }) .then(function (r) { // Refusé (400) : la somme des récompenses des questions dépasse la // récompense totale de la quête (voir routes/quests/quest_dialogues.py) // — message d'erreur demandé explicitement. La bulle en trop reste // affichée localement (non enregistrée) : l'auteur baisse son // montant ou celui d'une autre question pour repasser sous le budget. if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); }); }); } // Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de // mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations") // — juste un défaut de départ, l'auteur choisit ensuite librement dans // le menu déroulant de la réplique. function questNextDefaultSpeaker(lastSpeaker) { if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ'; return 'Joueur'; } function questAddBubble(status) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues = quest.dialogues || {}; var lines = quest.dialogues[status] || (quest.dialogues[status] = []); var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ'; lines.push({ type: 'dialogue', speaker: questNextDefaultSpeaker(lastSpeaker), text: '' }); renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } // "❓ Question" — voir questQuestionBubbleHtml ci-dessus. Défauts : 2 // choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt // (l'auteur règle le montant dans le header de la bulle). function questAddQuestion(status) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues = quest.dialogues || {}; var lines = quest.dialogues[status] || (quest.dialogues[status] = []); lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 }); renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } function questUpdateQuestionText(status, index, text) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status][index].text = text; questSaveDialogues(quest); } // Ajuste le NOMBRE de choix (2 à 8, voir db/quests/sanitize_quest_dialogues.py // ::_MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les // derniers, sans jamais toucher aux choix déjà remplis qui restent dans // la nouvelle taille. Recadre la bonne réponse sur le premier choix si // elle pointait vers un choix retiré. function questSetQuestionChoiceCount(status, index, value) { var quest = questById(_currentQuestModalId()); if (!quest) return; var line = quest.dialogues[status][index]; var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2)); while (line.choices.length < count) line.choices.push(''); while (line.choices.length > count) line.choices.pop(); if (line.correct_index >= line.choices.length) line.correct_index = 0; renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } function questSetQuestionChoiceText(status, index, choiceIndex, text) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status][index].choices[choiceIndex] = text; questSaveDialogues(quest); } function questSetQuestionCorrectIndex(status, index, choiceIndex) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status][index].correct_index = choiceIndex; questSaveDialogues(quest); } function questSetQuestionRewardAmount(status, index, value) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status][index].reward_amount = Math.max(0, parseInt(value, 10) || 0); questSaveDialogues(quest); } function questSetBubbleSpeaker(status, index, speaker) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status][index].speaker = speaker; renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } function questUpdateBubbleText(status, index, text) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status][index].text = text; questSaveDialogues(quest); } function questDeleteBubble(status, index) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues[status].splice(index, 1); renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } function _currentQuestModalId() { return _questModalCurrentId; } // ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ---------- // Affiche des cartes + "+ Nouvelle quête" DANS la modale de collision // déjà ouverte (voir renderCollisionWizardActionStep) ; choisir/créer une // quête ouvre ENSUITE la modale de FICHE (celle-ci par-dessus), avec un // bouton "✓ Utiliser cette quête" (disponible aussi depuis la modale de // dialogue) qui referme tout et finalise la règle — exactement le // comportement demandé : cliquer "Déclencher une quête" ouvre l'éditeur // de quête, pas un simple champ texte. function questPickerHtml(quests) { if (!quests.length) { return '

Aucune quête pour l\'instant.

'; } return '
' + quests.map(function (q, i) { return ''; }).join('') + '
'; } var _questPickerOnUse = null; function renderQuestPickerStep(onUse) { _questPickerOnUse = onUse; var body = collisionWizardBodyEl(); body.innerHTML = '

Quelle quête déclencher ?

Chargement…
' + ''; fetch('/game/' + GAME_SLUG + '/quests/list.json') .then(function (r) { return r.json(); }) .then(function (json) { QUESTS = json.quests; var list = document.getElementById('questPickerList'); if (list) list.innerHTML = questPickerHtml(QUESTS); }); } function questPickerChoose(questId) { openQuestFieldsModal(questId, _questPickerOnUse); } function questPickerCreate() { fetch('/game/' + GAME_SLUG + '/quests/new', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) .then(function (r) { return r.json(); }) .then(function (json) { QUESTS.unshift(json.quest); openQuestFieldsModal(json.quest.id, _questPickerOnUse); }); } document.addEventListener('DOMContentLoaded', function () { if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage(); });