Sépare la modale unique en deux étapes distinctes demandées par
l'utilisateur : #questFieldsModal ("je crée" — titre/objectif/
récompense/statut/résultat, taille normale) puis, sur un bouton dédié
"💬 Ajouter les dialogues", #questDialogueModal ("j'ajoute les
dialogues" — les 3 colonnes de répliques SEULES, occupant tout
l'écran). Un bouton "←" ramène à la fiche ; "✓ Utiliser cette quête"
reste disponible dans les deux, pour l'intégration avec l'éditeur de
collision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
387 lines
18 KiB
JavaScript
387 lines
18 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 : [];
|
|
|
|
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>';
|
|
}
|
|
|
|
function questDialogueBubbleHtml(line, status, index) {
|
|
return (
|
|
'<div class="questBubbleRow questBubbleRow-' + line.speaker + '">' +
|
|
'<div class="questBubble questBubble-' + line.speaker + '">' +
|
|
'<div class="questBubbleSpeaker" onclick="questToggleSpeaker(\'' + status + '\', ' + index + ')" title="Changer qui parle">' +
|
|
(line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') +
|
|
'</div>' +
|
|
'<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>' +
|
|
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
|
'</div>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
function questDialogueColumnHtml(quest, status) {
|
|
var lines = (quest.dialogues && quest.dialogues[status]) || [];
|
|
var bubblesHtml = lines.map(function (line, i) { return 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>' +
|
|
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">✚ Réplique</button>' +
|
|
'</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) {
|
|
if (!json.quest) return;
|
|
Object.assign(quest, 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.
|
|
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) { return r.json(); })
|
|
.then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; });
|
|
}
|
|
|
|
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({ speaker: lastSpeaker === 'pnj' ? 'joueur' : 'pnj', text: '' });
|
|
renderQuestDialogueModalBody(quest.id);
|
|
questSaveDialogues(quest);
|
|
}
|
|
|
|
function questToggleSpeaker(status, index) {
|
|
var quest = questById(_currentQuestModalId());
|
|
if (!quest) return;
|
|
var line = quest.dialogues[status][index];
|
|
line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur';
|
|
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();
|
|
});
|