Files
Forge-Engine/static/js/quests/quest-editor.js
T
williamandClaude Sonnet 5 521792fe00
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Dialogues de quête : bulles illimitées, n'importe quel objet, timeline reliée
- "qui parle" n'est plus réservé au personnage : N'IMPORTE QUEL objet de
  scène nommé (personnage, décor, fond — voir "ℹ️ Informations",
  screens/rendering/scene_object_names.py, ex-personnage_names.py) peut
  parler dans un dialogue.
- Confirmé/documenté : aucune limite au nombre de répliques par colonne
  (bouton "+ Réplique" reste toujours disponible).
- Bulle = header (menu déroulant du "qui parle") + body (le texte),
  centrée dans sa colonne à 70% de largeur, alignée verticalement et
  reliée à la suivante par un trait — remplace l'ancien alignement
  gauche/droite façon chat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:54:06 +02:00

432 lines
20 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.
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; });
}
// 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();
});