Quêtes : bulle "❓ Question" à choix multiples, à côté de "+ Réplique"
Build and deploy / test-python (push) Successful in 6m25s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Nouveau type de ligne dans un dialogue de quête (voir
db/quests/sanitize_quest_dialogues.py) : une bulle JAUNE, dans la même
chaîne verticale que les répliques (même alignement, reliée par un
trait) — header : nombre de choix (2 à 8) + récompense (score, montant
en points) ; body : la question, ses choix et un bouton radio pour
désigner la bonne réponse. Aucune limite au nombre de questions par
colonne, comme pour une réplique.

Les lignes de dialogue portent maintenant explicitement
{"type": "dialogue", ...} (au lieu d'un objet sans type) pour
distinguer les deux formes — migration de forme, tests mis à jour.

Portée de ce commit : l'ÉDITEUR (créer/modifier une question). Le jeu
lui-même (afficher la question au joueur, vérifier la réponse,
attribuer la récompense) reste à construire — le widget "💬 Boîte de
dialogue" ne sait aujourd'hui afficher qu'une réplique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-03 14:48:14 +02:00
co-authored by Claude Sonnet 5
parent f2ed602459
commit e972acf9e6
5 changed files with 242 additions and 34 deletions
+113 -3
View File
@@ -231,14 +231,66 @@ function questDialogueBubbleHtml(line, status, index) {
);
}
function questEscAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
// "❓ 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="8" 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 questDialogueBubbleHtml(line, status, i); }).join('');
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>' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">✚ Réplique</button>' +
'<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>'
);
}
@@ -351,11 +403,69 @@ function questAddBubble(status) {
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: '' });
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(8, 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;