- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme des points des "❓ Question" ne peut plus dépasser recompense_score (routes/quests/quest_dialogues.py à l'enregistrement des dialogues, quest_update.py si on abaisse la récompense sous ce qui est déjà réparti) — message d'erreur explicite (400) dans les deux sens, affiché via alert() côté éditeur (quest-editor.js), jamais enregistré silencieusement dans un état incohérent. - Nombre de choix par question plafonné à 4 (au lieu de 8). - Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte de dialogue" — screens/rendering/dialogue_box_style.py, même panneau "🎨 Style") : - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une question se résout au clic sur un choix, pas de "Suivant"). - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible une fois posé (contrairement aux boîtes de dialogue/quiz, masquées par défaut). - Moteur d'exécution (static/js/play/dialogue-box-controller.js, refonte) : une conversation de quête alterne maintenant répliques (boîte de dialogue) et questions (boîte à quiz) selon le type de chaque ligne. Bonne réponse -> crédite le score (compteur runtime dédié, jamais lié au score du moteur document) et avance ; mauvaise réponse -> rien, la question reste affichée pour réessayer ; aucune boîte à quiz posée -> la question est ignorée plutôt que de bloquer la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait des questions, toutes répondues) fait passer la quête "terminee" (en mémoire seulement, comme le reste de ce système — voir accepter/ refuser une offre de quête) : le joueur peut alors quitter la scène normalement, ces widgets n'étant jamais des obstacles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
562 lines
27 KiB
JavaScript
562 lines
27 KiB
JavaScript
// ---------- É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 ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '<span class="hint">—</span>';
|
||
return (
|
||
'<tr class="questListRow" onclick="openQuestFieldsModal(' + quest.id + ')">' +
|
||
'<td><strong>' + (quest.title || '').replace(/</g, '<') + '</strong></td>' +
|
||
'<td>' + (quest.objectif ? quest.objectif.replace(/</g, '<') : '<span class="hint">—</span>') + '</td>' +
|
||
'<td><span class="questPill questPill-score">🏆 +' + (quest.recompense_score || 0) + '</span></td>' +
|
||
'<td><span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span></td>' +
|
||
'<td>' + resultatHtml + '</td>' +
|
||
'<td class="fieldsTableActions">' +
|
||
'<button type="button" class="button is-small is-danger" title="Supprimer" onclick="event.stopPropagation(); questDeleteFromList(' + quest.id + ')">🗑️</button>' +
|
||
'</td>' +
|
||
'</tr>'
|
||
);
|
||
}
|
||
|
||
function renderQuestListPage() {
|
||
var container = document.getElementById('questsTabPanel');
|
||
if (!container) return;
|
||
var wrap = document.getElementById('questsListWrap');
|
||
if (!QUESTS.length) {
|
||
wrap.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
|
||
return;
|
||
}
|
||
wrap.innerHTML =
|
||
'<div class="fieldsTableWrap"><table class="table is-fullwidth is-hoverable fieldsTable">' +
|
||
'<thead><tr><th>Titre</th><th>Objectif</th><th>Récompense</th><th>Statut</th><th>Résultat</th><th></th></tr></thead>' +
|
||
'<tbody>' + QUESTS.map(questListRowHtml).join('') + '</tbody>' +
|
||
'</table></div>';
|
||
}
|
||
|
||
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 =
|
||
'<div class="modal-background" onclick="closeQuestFieldsModal()"></div>' +
|
||
'<div class="modal-content questFieldsModalContent"><div class="box">' +
|
||
'<div id="questFieldsModalBody"></div>' +
|
||
'</div></div>';
|
||
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 (
|
||
'<div class="questFieldsGrid">' +
|
||
'<label class="questField"><span>Titre</span><input type="text" class="input" value="' + (quest.title || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'title', this.value)\"></label>" +
|
||
'<label class="questField questField-wide"><span>Objectif</span><input type="text" class="input" value="' + (quest.objectif || '').replace(/"/g, '"') + '" onchange="questUpdateField(' + quest.id + ", 'objectif', this.value)\"></label>" +
|
||
'<label class="questField"><span>Récompense (score)</span><input type="number" class="input" value="' + (quest.recompense_score || 0) + '" onchange="questUpdateField(' + quest.id + ", 'recompense_score', this.value)\"></label>" +
|
||
'<label class="questField"><span>Statut</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'status', this.value)\">" +
|
||
QUEST_STATUS_ORDER.map(function (s) { return '<option value="' + s + '"' + (quest.status === s ? ' selected' : '') + '>' + QUEST_STATUS_LABELS[s] + '</option>'; }).join('') +
|
||
'</select></label>' +
|
||
'<label class="questField"><span>Résultat</span><select class="input" onchange="questUpdateField(' + quest.id + ", 'resultat', this.value)\">" +
|
||
'<option value=""' + (!quest.resultat ? ' selected' : '') + '>—</option>' +
|
||
'<option value="reussi"' + (quest.resultat === 'reussi' ? ' selected' : '') + '>Réussi</option>' +
|
||
'<option value="echoue"' + (quest.resultat === 'echoue' ? ' selected' : '') + '>Échoué</option>' +
|
||
'</select></label>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function renderQuestFieldsModalBody(questId) {
|
||
var quest = questById(questId);
|
||
var body = document.getElementById('questFieldsModalBody');
|
||
if (!quest || !body) return;
|
||
var useBtn = _questModalOnUse
|
||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">✓ Utiliser cette quête</button>'
|
||
: '';
|
||
body.innerHTML =
|
||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||
'<h2 class="title is-5" style="margin:0;">🗺️ ' + (quest.title || '').replace(/</g, '<') + '</h2>' +
|
||
'<button type="button" class="button is-small" onclick="closeQuestFieldsModal()">✕</button>' +
|
||
'</div>' +
|
||
questFieldRowHtml(quest) +
|
||
'<div class="fieldRow" style="justify-content:space-between; align-items:center; margin-top:6px;">' +
|
||
'<button type="button" class="collisionRuleAddBtn" onclick="openQuestDialogueModal(' + quest.id + ')">💬 Ajouter les dialogues</button>' +
|
||
useBtn +
|
||
'</div>';
|
||
}
|
||
|
||
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
|
||
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
|
||
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
|
||
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
|
||
// valeur déjà enregistrée.
|
||
function questSpeakerOptions(currentSpeaker) {
|
||
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
|
||
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||
return options;
|
||
}
|
||
|
||
// Une bulle = header (qui parle, menu déroulant des objets de scène
|
||
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
|
||
// distinctes demandées explicitement, pas un seul bloc. Toujours centrée
|
||
// dans sa colonne, 70% de la largeur (voir .questBubble, static/style.css) —
|
||
// aucune limite au nombre de bulles par colonne (voir questAddBubble).
|
||
function questDialogueBubbleHtml(line, status, index) {
|
||
var kind = questBubbleKind(line.speaker);
|
||
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
|
||
return '<option value="' + name.replace(/"/g, '"') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>';
|
||
}).join('');
|
||
return (
|
||
'<div class="questBubbleRow">' +
|
||
'<div class="questBubble questBubble-' + kind + '">' +
|
||
'<div class="questBubbleHeader">' +
|
||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||
'</div>' +
|
||
'<div class="questBubbleBody">' +
|
||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
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 (
|
||
'<div class="questChoiceRow">' +
|
||
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
|
||
(choiceIndex === correctIndex ? 'checked' : '') +
|
||
' onchange="questSetQuestionCorrectIndex(\'' + status + '\', ' + index + ', ' + choiceIndex + ')">' +
|
||
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + questEscAttr(choice) + '" ' +
|
||
'onchange="questSetQuestionChoiceText(\'' + status + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function questQuestionBubbleHtml(line, status, index) {
|
||
var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join('');
|
||
return (
|
||
'<div class="questBubbleRow">' +
|
||
'<div class="questBubble questBubble-question">' +
|
||
'<div class="questBubbleHeader">' +
|
||
'<label class="questQuestionHeaderField" title="Nombre de choix">🔢 <input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
|
||
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
|
||
'<label class="questQuestionHeaderField" title="Récompense (points de score)">🏆 <input type="number" min="0" value="' + line.reward_amount + '" ' +
|
||
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
|
||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||
'</div>' +
|
||
'<div class="questBubbleBody">' +
|
||
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + questEscAttr(line.text) + '" ' +
|
||
'onchange="questUpdateQuestionText(\'' + status + '\', ' + index + ', this.value)">' +
|
||
'<div class="questChoicesList">' + choicesHtml + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
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 (
|
||
'<div class="questDialogueColumn">' +
|
||
'<h4 class="questDialogueColumnTitle">' + QUEST_STATUS_LABELS[status] + '</h4>' +
|
||
'<div class="questDialogueColumnBubbles" id="questDialogueColumn-' + status + '">' + bubblesHtml + '</div>' +
|
||
'<div class="questDialogueColumnButtons">' +
|
||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">✚ Réplique</button>' +
|
||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn questQuestionAddBtn" onclick="questAddQuestion(\'' + status + '\')">❓ Question</button>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
// 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 =
|
||
'<div class="modal-background" onclick="closeQuestDialogueModal()"></div>' +
|
||
'<div class="modal-content questDialogueModalContent"><div class="box">' +
|
||
'<div id="questDialogueModalBody"></div>' +
|
||
'</div></div>';
|
||
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
|
||
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">✓ Utiliser cette quête</button>'
|
||
: '';
|
||
body.innerHTML =
|
||
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
|
||
'<div style="display:flex; align-items:center; gap:10px;">' +
|
||
'<button type="button" class="button is-small" onclick="questBackToFieldsModal(' + quest.id + ')" title="Retour à la fiche">←</button>' +
|
||
'<h2 class="title is-5" style="margin:0;">🗺️ ' + (quest.title || '').replace(/</g, '<') + ' — dialogues</h2>' +
|
||
'</div>' +
|
||
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
|
||
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<p class="hint mt-2 mb-2">Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :</p>' +
|
||
'<div class="questDialogueColumns">' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '</div>';
|
||
}
|
||
|
||
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 '<p class="hint mb-2">Aucune quête pour l\'instant.</p>';
|
||
}
|
||
return '<div class="collisionWizardChoices">' + quests.map(function (q, i) {
|
||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="questPickerChoose(' + q.id + ')">' +
|
||
'<span class="emoji">🗺️</span><span>' + (q.title || '').replace(/</g, '<') + '</span>' +
|
||
'</button>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
var _questPickerOnUse = null;
|
||
|
||
function renderQuestPickerStep(onUse) {
|
||
_questPickerOnUse = onUse;
|
||
var body = collisionWizardBodyEl();
|
||
body.innerHTML = '<p class="hint mb-2">Quelle quête déclencher ?</p><div id="questPickerList">Chargement…</div>' +
|
||
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">✚ Nouvelle quête</button>';
|
||
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();
|
||
});
|