Éditeur de quêtes : titre/objectif/récompense/statut/résultat + dialogue à 3 colonnes
Build and deploy / test-python (push) Successful in 6m14s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Nouvel espace "🗺️ Quêtes" (game-wide, comme les événements
personnalisés) : liste de cartes + "+ Nouvelle quête", chaque carte
ouvrant une modale d'arbre de dialogue en bulles de théâtre
("speaker: texte"), une colonne par statut de quête (nouvelle/en
cours/terminée) puisque le dialogue joué dépend de l'état de la
quête au moment où le joueur parle au PNJ. Bulles alternées
joueur/PNJ, couleur différente selon qui parle.

L'action "Déclencher une quête" de l'éditeur de collision n'est plus
un champ texte libre : elle ouvre directement CETTE MÊME modale
(picker de quêtes existantes + création à la volée), et finalise la
règle avec l'id numérique de la quête choisie — collision_rules.py
migré en conséquence (quete_id devient un entier, comme evenement_id).

Corrige aussi la miniature d'objet de la carte "🧩 Collision", 3× plus
grande (120px) comme demandé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 20:24:57 +02:00
co-authored by Claude Sonnet 5
parent 4391b36d45
commit 51427b5297
28 changed files with 881 additions and 29 deletions
+301
View File
@@ -0,0 +1,301 @@
// ---------- Éditeur de quêtes ----------
// Module partagé entre templates/quests.html (page dédiée : liste de
// cartes + "+ Nouvelle quête") et static/js/scenes/collision-rules-editor.js
// (l'assistant "+ Action" de l'éditeur de collision ouvre CETTE MÊME
// modale de dialogue quand l'auteur choisit "Déclencher une quête",
// plutôt qu'un simple champ texte pour un id). La modale (arbre de
// dialogue) est créée dynamiquement (ensureQuestDialogueModal) plutôt que
// dupliquée dans deux templates.
//
// 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 l'éditeur : 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 (quests.html)
// si présent, sinon vide tant qu'on n'a pas encore fetch (contexte
// éditeur de collision, voir questPickerEnsureLoaded).
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
function questById(questId) {
return QUESTS.find(function (q) { return q.id === questId; });
}
function questCardHtml(quest, i) {
var resultatHtml = quest.resultat ? '<span class="questPill questPill-resultat">' + QUEST_RESULT_LABELS[quest.resultat] + '</span>' : '';
return (
'<div class="questCard" style="animation-delay:' + (i * 50) + 'ms" onclick="openQuestDialogueModal(' + quest.id + ')">' +
'<div class="questCardHeader">' +
'<h3 class="questCardTitle">' + (quest.title || '').replace(/</g, '&lt;') + '</h3>' +
'<span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span>' +
'</div>' +
'<p class="questCardObjectif">' + (quest.objectif ? quest.objectif.replace(/</g, '&lt;') : '<span class="hint">Aucun objectif défini.</span>') + '</p>' +
'<div class="questCardFooter">' +
'<span class="questPill questPill-score">🏆 +' + (quest.recompense_score || 0) + '</span>' +
resultatHtml +
'</div>' +
'</div>'
);
}
function renderQuestListPage() {
var container = document.getElementById('questsGrid');
if (!container) return;
if (!QUESTS.length) {
container.innerHTML = '<p class="hint">Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.</p>';
return;
}
container.innerHTML = QUESTS.map(questCardHtml).join('');
}
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();
openQuestDialogueModal(json.quest.id);
});
}
// ---------- Modale "arbre de dialogue" ----------
// Créée une seule fois (au premier appel), réutilisée ensuite — partagée
// entre la page dédiée et l'assistant "+ Action" de l'éditeur de
// collision, jamais dupliquée dans les templates.
var _questModalOnUse = null;
function ensureQuestDialogueModal() {
var modal = document.getElementById('questDialogueModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal';
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;
}
// `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" épinglé en haut de la modale.
function openQuestDialogueModal(questId, onUse) {
_questModalOnUse = onUse || null;
var modal = ensureQuestDialogueModal();
modal.classList.add('is-active');
renderQuestDialogueModalBody(questId);
}
function closeQuestDialogueModal() {
var modal = document.getElementById('questDialogueModal');
if (modal) modal.classList.remove('is-active');
_questModalOnUse = null;
_questModalCurrentId = null;
}
function questUseThisQuest(questId) {
var quest = questById(questId);
if (_questModalOnUse && quest) _questModalOnUse(quest);
closeQuestDialogueModal();
}
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 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, '&lt;') + '</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>'
);
}
var _questModalCurrentId = null;
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;">' +
'<h2 class="title is-5" style="margin:0;">🗺️ ' + (quest.title || '').replace(/</g, '&lt;') + '</h2>' +
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
'</div>' +
'</div>' +
questFieldRowHtml(quest) +
'<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();
if (document.getElementById('questDialogueModal') && document.getElementById('questDialogueModal').classList.contains('is-active')) {
renderQuestDialogueModalBody(questId);
}
});
}
// 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) ----------
// Récupère la liste à jour (jamais présente d'avance dans scene_edit.html,
// contrairement à quests.html) puis 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 dialogue (celle-ci empilée par-dessus), avec un
// bouton "✓ Utiliser cette quête" qui referme les deux 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) {
openQuestDialogueModal(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);
openQuestDialogueModal(json.quest.id, _questPickerOnUse);
});
}
document.addEventListener('DOMContentLoaded', function () {
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
});
+11 -16
View File
@@ -29,7 +29,10 @@ function collisionRuleTriggerSummary(rule) {
}
function collisionRuleActionSummary(action) {
if (action.type === 'quete') return COLLISION_ACTION_LABELS.quete + ' « ' + action.quete_id + ' »';
if (action.type === 'quete') {
var quest = (typeof questById === 'function') ? questById(action.quete_id) : null;
return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »';
}
if (action.type === 'evenement') {
var ev = CUSTOM_EVENTS_MAP[action.evenement_id];
return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »';
@@ -227,20 +230,19 @@ function collisionWizardSaveRule(action) {
}
// Chaque type d'action demande éventuellement une précision avant de
// pouvoir se finaliser (quête -> son id ; événement -> lequel) — "attaque"
// n'a besoin de rien de plus, se finalise directement.
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de
// plus, se finalise directement.
function collisionWizardChooseAction(type, nested) {
if (type === 'attaque' || type === 'interagir') {
collisionWizardFinalizeAction({ type: type }, nested);
return;
}
if (type === 'quete') {
collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :</p>' +
'<div class="field is-grouped">' +
'<div class="control is-expanded"><input type="text" id="collisionWizardQueteId" class="input" placeholder="ex. quete-001"></div>' +
'<div class="control"><button type="button" class="button primary" onclick="collisionWizardSubmitQuete(' + (nested ? 'true' : 'false') + ')">Valider</button></div>' +
'</div>';
renderQuestPickerStep(function (quest) {
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested);
});
return;
}
if (type === 'evenement') {
@@ -258,13 +260,6 @@ function collisionWizardChooseAction(type, nested) {
}
}
function collisionWizardSubmitQuete(nested) {
var input = document.getElementById('collisionWizardQueteId');
var queteId = input && input.value.trim();
if (!queteId) { input.focus(); return; }
collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested);
}
document.addEventListener('DOMContentLoaded', function () {
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
});