Quiz jouable : budget de récompense, boîte à quiz, widget score
- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme des points des "❓ Question" ne peut plus dépasser recompense_score (routes/quests/quest_dialogues.py à l'enregistrement des dialogues, quest_update.py si on abaisse la récompense sous ce qui est déjà réparti) — message d'erreur explicite (400) dans les deux sens, affiché via alert() côté éditeur (quest-editor.js), jamais enregistré silencieusement dans un état incohérent. - Nombre de choix par question plafonné à 4 (au lieu de 8). - Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte de dialogue" — screens/rendering/dialogue_box_style.py, même panneau "🎨 Style") : - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une question se résout au clic sur un choix, pas de "Suivant"). - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible une fois posé (contrairement aux boîtes de dialogue/quiz, masquées par défaut). - Moteur d'exécution (static/js/play/dialogue-box-controller.js, refonte) : une conversation de quête alterne maintenant répliques (boîte de dialogue) et questions (boîte à quiz) selon le type de chaque ligne. Bonne réponse -> crédite le score (compteur runtime dédié, jamais lié au score du moteur document) et avance ; mauvaise réponse -> rien, la question reste affichée pour réessayer ; aucune boîte à quiz posée -> la question est ignorée plutôt que de bloquer la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait des questions, toutes répondues) fait passer la quête "terminee" (en mémoire seulement, comme le reste de ce système — voir accepter/ refuser une offre de quête) : le joueur peut alors quitter la scène normalement, ces widgets n'étant jamais des obstacles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e972acf9e6
commit
b5546df044
@@ -11,6 +11,7 @@ global.gameData = {};
|
||||
const {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
|
||||
forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeWidget(objectId) {
|
||||
@@ -154,3 +155,126 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
|
||||
assert.doesNotThrow(() => forgeDialogueBoxAdvance(999));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
// ---------- Enchaînement dialogue + question ("❓ Question") ----------
|
||||
|
||||
function fakeQuizWidget(objectId) {
|
||||
const roles = { header: { textContent: '' }, choices: { innerHTML: '' } };
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
querySelector: sel => {
|
||||
if (sel === '[data-quiz-role="header"]') return roles.header;
|
||||
if (sel === '[data-quiz-role="choices"]') return roles.choices;
|
||||
return null;
|
||||
},
|
||||
_roles: roles, _classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
return {
|
||||
querySelector(sel) {
|
||||
if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null;
|
||||
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null; // forgeQuizBoxWidgetById
|
||||
if (sel.indexOf('.dialogueBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return dialogueWidget;
|
||||
if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.gameData.quests.push({
|
||||
id: 10, status: 'en_cours',
|
||||
dialogues: {
|
||||
nouvelle: [],
|
||||
en_cours: [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
],
|
||||
terminee: [],
|
||||
},
|
||||
});
|
||||
window.forgeQuizScore = 0;
|
||||
});
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal
|
||||
|
||||
forgeDialogueBoxAdvance(42); // -> ligne "question"
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
assert.equal(quizWidget._roles.header.textContent, '2+2 ?');
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/);
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('mauvaise réponse au quiz : aucun effet, la question reste affichée', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1)
|
||||
assert.equal(quizWidget._classes.has('is-active'), true); // reste affichée
|
||||
assert.equal(window.forgeQuizScore, 0); // aucun point
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la ligne suivante', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
forgeQuizBoxAnswer(99, 1); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 5);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), true);
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee"
|
||||
assert.equal(forgeQuestById(10).status, 'terminee');
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz
|
||||
try {
|
||||
forgeShowDialogueBox(10);
|
||||
forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante
|
||||
assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => {
|
||||
const values = [{ textContent: '' }, { textContent: '' }];
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 42;
|
||||
global.document = { querySelectorAll: () => values };
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
assert.equal(values[0].textContent, 42);
|
||||
assert.equal(values[1].textContent, 42);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ----------
|
||||
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
|
||||
// Fait le lien entre l'éditeur de collision (action "quete", voir
|
||||
// static/js/play/collision-rules-controller.js) et le widget d'interface
|
||||
// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||
// static/js/play/collision-rules-controller.js) et les widgets d'interface
|
||||
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||
// "quete", affiche la réplique en cours du dialogue correspondant au
|
||||
// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py),
|
||||
// avance d'une réplique à chaque clic sur "Suivant".
|
||||
// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la
|
||||
// quête (gameData.quests, voir full_game_payload.py) ligne par ligne —
|
||||
// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton
|
||||
// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir
|
||||
// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si
|
||||
// posée sur la scène — sinon ignorée silencieusement, la conversation
|
||||
// avance sans elle) : cliquer la BONNE réponse crédite le score du
|
||||
// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la
|
||||
// question reste affichée (le joueur peut réessayer).
|
||||
//
|
||||
// Une fois le dialogue "nouvelle" épuisé : la boîte bascule en écran
|
||||
// d'OFFRE ("Quête : <titre>" / objectif / boutons Accepter-Refuser) au
|
||||
// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser
|
||||
// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle",
|
||||
// la prochaine interaction rejoue exactement le même dialogue). Une fois
|
||||
// le dialogue "en_cours" épuisé (donc, s'il contenait des questions,
|
||||
// toutes répondues correctement), la quête passe "terminee" — le joueur
|
||||
// peut alors quitter la scène normalement (aucun blocage, ces widgets ne
|
||||
// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours
|
||||
// (gameData.quests n'est qu'un instantané par joueur, voir
|
||||
// full_game_payload.py) : jamais persisté en base, qui garde le statut
|
||||
// de DÉPART pour toute nouvelle partie.
|
||||
//
|
||||
// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié
|
||||
// à ce système de quête/quiz — distinct du score "classique" du moteur
|
||||
// document (db/scoring/, action de flow "Modifier un score"), qui
|
||||
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors
|
||||
// de propos pour ce sous-système entièrement client (voir
|
||||
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
|
||||
// serveur").
|
||||
//
|
||||
// Une fois le dialogue épuisé : si la quête est encore "nouvelle", la
|
||||
// boîte bascule en écran d'OFFRE ("Quête : <titre>" / objectif / boutons
|
||||
// Accepter-Refuser) au lieu de se masquer — accepter fait passer la
|
||||
// quête "en_cours" (LOCAL à cette partie, jamais persisté en base :
|
||||
// gameData.quests n'est qu'un instantané par joueur, voir
|
||||
// full_game_payload.py ; muter la ligne en base changerait le statut de
|
||||
// DÉPART de la quête pour toutes les parties futures) ; refuser ferme
|
||||
// simplement la boîte SANS changer le statut — la quête reste "nouvelle",
|
||||
// donc la prochaine interaction rejoue EXACTEMENT le même dialogue depuis
|
||||
// le début (impossible d'avancer sans accepter). Pour tout autre statut
|
||||
// (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin.
|
||||
// Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a
|
||||
// posé aucun widget sur l'écran — même esprit que le reste du moteur.
|
||||
// posé aucun widget requis sur l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
|
||||
function forgeQuestById(questId) {
|
||||
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
||||
}
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------- Boîte de dialogue ----------
|
||||
|
||||
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
||||
}
|
||||
@@ -55,6 +80,57 @@ function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
}
|
||||
|
||||
// ---------- Boîte à quiz ----------
|
||||
|
||||
function forgeQuizBoxWidgetOnCurrentScreen() {
|
||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||
}
|
||||
|
||||
function forgeQuizBoxWidgetById(objectId) {
|
||||
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
|
||||
}
|
||||
|
||||
function forgeHideQuizBoxOnCurrentScreen() {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
}
|
||||
|
||||
// ---------- Score ----------
|
||||
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
}
|
||||
|
||||
function forgeAddQuizScore(amount) {
|
||||
window.forgeQuizScore += (amount || 0);
|
||||
forgeUpdateAllScoreWidgets();
|
||||
}
|
||||
|
||||
// ---------- Enchaînement dialogue/question ----------
|
||||
|
||||
// Affiche la ligne COURANTE de l'état de conversation `dialogueObjectId`
|
||||
// (voir forgeDialogueBoxState) — une ligne "dialogue" dans la boîte de
|
||||
// dialogue, une ligne "question" dans la boîte à quiz (voir
|
||||
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
|
||||
// n'est posée : impossible de bloquer la conversation indéfiniment).
|
||||
function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
var line = state.lines[state.index];
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, line);
|
||||
return;
|
||||
}
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
forgeResetDialogueBoxFooter(widget, dialogueObjectId);
|
||||
forgeRenderDialogueBoxLine(widget, line);
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
||||
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
||||
// comme le reste du moteur 2D).
|
||||
@@ -65,39 +141,95 @@ function forgeShowDialogueBox(questId) {
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
// Aucune réplique posée pour ce statut : une quête "nouvelle" propose
|
||||
// Aucune ligne posée pour ce statut : une quête "nouvelle" propose
|
||||
// quand même directement l'offre (accepter/refuser), les autres
|
||||
// statuts restent silencieux (rien à montrer).
|
||||
if (!lines.length) {
|
||||
if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest);
|
||||
return;
|
||||
}
|
||||
forgeResetDialogueBoxFooter(widget, objectId);
|
||||
forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 };
|
||||
forgeRenderDialogueBoxLine(widget, lines[0]);
|
||||
widget.classList.add('is-active');
|
||||
forgeShowCurrentConversationLine(objectId);
|
||||
}
|
||||
|
||||
// Avance d'une ligne (fin de dialogue -> question, fin de question
|
||||
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par
|
||||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer).
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
var quest = forgeQuestById(state.questId);
|
||||
if (quest && quest.status === 'nouvelle') {
|
||||
if (widget) forgeShowQuestOffer(widget, dialogueObjectId, quest);
|
||||
return;
|
||||
}
|
||||
// Tout le dialogue "en_cours" épuisé (donc, s'il contenait des
|
||||
// questions, toutes répondues correctement) -> quête terminée. Voir
|
||||
// demande utilisateur : "quand le joueur a répondu à toutes les
|
||||
// questions la quête passe au statut terminé".
|
||||
if (quest && quest.status === 'en_cours') quest.status = 'terminee';
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[dialogueObjectId];
|
||||
return;
|
||||
}
|
||||
forgeShowCurrentConversationLine(dialogueObjectId);
|
||||
}
|
||||
|
||||
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
|
||||
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)").
|
||||
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)") — sans
|
||||
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
|
||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||
function forgeDialogueBoxAdvance(objectId) {
|
||||
var state = forgeDialogueBoxState[objectId];
|
||||
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||
if (!state || !widget) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
var quest = forgeQuestById(state.questId);
|
||||
if (quest && quest.status === 'nouvelle') {
|
||||
forgeShowQuestOffer(widget, objectId, quest);
|
||||
return;
|
||||
}
|
||||
widget.classList.remove('is-active');
|
||||
delete forgeDialogueBoxState[objectId];
|
||||
return;
|
||||
}
|
||||
forgeRenderDialogueBoxLine(widget, state.lines[state.index]);
|
||||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||||
forgeAdvanceConversation(objectId);
|
||||
}
|
||||
|
||||
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||||
// l'écran — si l'auteur n'en a posé aucune, la question est ignorée
|
||||
// (avance directement) plutôt que de bloquer la conversation pour
|
||||
// toujours en attendant une réponse qu'aucun widget ne peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, line) {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount,
|
||||
};
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (header) header.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
widget.classList.add('is-active');
|
||||
}
|
||||
|
||||
// Appelée par le clic sur un choix (voir render_scene_object.py côté
|
||||
// éditeur — les vrais choix sont injectés EN JEU par forgeShowQuizBox
|
||||
// ci-dessus, jamais rendus côté serveur). Mauvaise réponse : rien, la
|
||||
// question reste affichée. Bonne réponse : crédite le score, masque la
|
||||
// boîte, avance la conversation.
|
||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state) return;
|
||||
if (choiceIndex !== state.correctIndex) return;
|
||||
forgeAddQuizScore(state.rewardAmount);
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
var dialogueObjectId = state.dialogueObjectId;
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
forgeAdvanceConversation(dialogueObjectId);
|
||||
}
|
||||
|
||||
// ---------- Offre de quête (Accepter/Refuser) ----------
|
||||
|
||||
// Écran d'offre : header = "Quête : <titre>", body = l'objectif, pied =
|
||||
// Accepter/Refuser au lieu du "Suivant" habituel.
|
||||
function forgeShowQuestOffer(widget, objectId, quest) {
|
||||
@@ -143,5 +275,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
||||
// se déplace lui-même.
|
||||
function forgeSolidObstacles(screenData, movingId) {
|
||||
return (screenData.elements || []).filter(function (o) {
|
||||
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false);
|
||||
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ function showScreen(id) {
|
||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||
// joueur ait déjà marqué des points ailleurs resterait figé à "0"
|
||||
// (valeur d'exemple rendue côté serveur) jusqu'au prochain point gagné.
|
||||
if (typeof forgeUpdateAllScoreWidgets === 'function') forgeUpdateAllScoreWidgets();
|
||||
}
|
||||
|
||||
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
|
||||
|
||||
@@ -262,7 +262,7 @@ function questQuestionBubbleHtml(line, status, index) {
|
||||
'<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 + '" ' +
|
||||
'<label class="questQuestionHeaderField" title="Nombre de choix">🔢 <input type="number" min="2" max="4" 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>' +
|
||||
@@ -362,10 +362,15 @@ function questUpdateField(questId, field, value) {
|
||||
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);
|
||||
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||
.then(function (res) {
|
||||
// Refusé (400) : recompense_score abaissé sous le total déjà
|
||||
// réparti entre les questions (voir routes/quests/quest_update.py)
|
||||
// — message d'erreur demandé explicitement, et le champ est
|
||||
// remis à sa valeur RÉELLE (inchangée côté serveur).
|
||||
if (!res.ok) { alert(res.json.error || "Impossible d'enregistrer."); renderQuestFieldsModalBody(questId); return; }
|
||||
if (!res.json.quest) return;
|
||||
Object.assign(quest, res.json.quest);
|
||||
renderQuestListPage();
|
||||
renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables
|
||||
});
|
||||
@@ -385,7 +390,15 @@ function questUpdateField(questId, field, value) {
|
||||
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) {
|
||||
// Refusé (400) : la somme des récompenses des questions dépasse la
|
||||
// récompense totale de la quête (voir routes/quests/quest_dialogues.py)
|
||||
// — message d'erreur demandé explicitement. La bulle en trop reste
|
||||
// affichée localement (non enregistrée) : l'auteur baisse son
|
||||
// montant ou celui d'une autre question pour repasser sous le budget.
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); });
|
||||
});
|
||||
}
|
||||
|
||||
// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de
|
||||
@@ -437,7 +450,7 @@ 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));
|
||||
var count = Math.max(2, Math.min(4, 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;
|
||||
|
||||
Reference in New Issue
Block a user