Files
Forge-Engine/static/js/quests/quest-editor.js
T
williamandClaude Sonnet 5 077af2234d
Build and deploy / test-python (push) Successful in 7m9s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Fix "+ Réplique" qui remplaçait la bulle au lieu d'en ajouter une
Bug : questSaveDialogues() adoptait la réponse du serveur comme nouvel
état local après chaque enregistrement. Une bulle fraîchement ajoutée a
un texte encore vide (rien tapé) — sanitize_quest_dialogues.py la
rejette légitimement (jamais de réplique sans texte persistée) — donc
la réponse renvoyait un tableau amputé de cette bulle, que le client
adoptait aveuglément, effaçant la bulle en cours d'écriture. Le clic
suivant sur "+ Réplique" repartait donc du même état qu'avant, semblant
"remplacer" la bulle plutôt que d'en ajouter une seconde. Le client ne
resynchronise plus jamais son état depuis la réponse d'enregistrement —
il est déjà la seule source de vérité pendant l'édition.

Colonnes de dialogue : occupent maintenant toute la hauteur disponible
de la modale plein écran et défilent chacune indépendamment (titre et
bouton "+ Réplique" restent fixes), au lieu d'une hauteur minimale fixe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:08:41 +02:00

439 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- É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, '&lt;') + '</strong></td>' +
'<td>' + (quest.objectif ? quest.objectif.replace(/</g, '&lt;') : '<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, '&quot;') + '" 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, '&quot;') + '" 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, '&lt;') + '</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, '&quot;') + '"' + (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, '&lt;') + '</textarea>' +
'</div>' +
'</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, '&lt;') + ' — 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.
// 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 }),
});
}
// 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({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' });
renderQuestDialogueModalBody(quest.id);
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, '&lt;') + '</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();
});