Ajoute la fonctionnalite quiz autonome/plein ecran a la boite a quiz
Build and deploy / test-python (push) Successful in 12m10s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Introduit la double categorie de modeles (boite de dialogue / page de
quiz plein ecran) avec plein ecran, minuteur, score integre et ecran de
resultat pour les modeles page ; ajoute les modeles "Manga" (boite et
page) et "Classique" (page), pilotables aussi par l'assistant IA Ruby.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-13 15:07:23 +02:00
co-authored by Claude Sonnet 5
parent 559331f9cf
commit 7db4803b93
26 changed files with 3173 additions and 60 deletions
@@ -14,9 +14,10 @@ global.gameData = {};
const {
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout, forgeUpdateAllScoreWidgets,
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus,
forgeComputeFinalScormStatus, forgeEscapeCameraForFullscreen,
forgeQuizResultQuit,
} = require('../dialogue-box-controller.js');
function fakeWidget(objectId) {
@@ -28,7 +29,10 @@ function fakeWidget(objectId) {
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) },
classList: {
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c),
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
},
querySelector: sel => {
if (sel === '[data-dialogue-role="header"]') return roles.header;
if (sel === '[data-dialogue-role="body"]') return roles.body;
@@ -39,6 +43,45 @@ function fakeWidget(objectId) {
};
}
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene"
// (templates/play.html) est transformé (centré via translate), ce qui
// casse position:fixed pour tout descendant : un plein écran ne peut
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
const screenEl = { getAttribute: attr => (attr === 'data-screen-id' ? '7' : null) };
const attrs = {};
const classes = new Set();
const bodyChildren = [];
const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } };
const widget = {
parentNode: null,
closest: sel => (sel === '.playScreen' ? screenEl : null),
setAttribute: (k, v) => { attrs[k] = v; },
classList: { add: c => classes.add(c) },
};
const previousDoc = global.document;
global.document = { body };
try {
forgeEscapeCameraForFullscreen(widget);
assert.equal(bodyChildren[0], widget);
assert.equal(widget.parentNode, body);
assert.equal(attrs['data-owner-screen-id'], '7');
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
} finally { global.document = previousDoc; }
});
test('forgeEscapeCameraForFullscreen ne fait rien si déjà enfant de <body> (jamais déplacé deux fois)', () => {
const body = { appendChild: () => { throw new Error('ne doit pas être rappelé'); } };
const widget = { parentNode: body };
const previousDoc = global.document;
global.document = { body };
try {
assert.doesNotThrow(() => forgeEscapeCameraForFullscreen(widget));
} finally { global.document = previousDoc; }
});
test.beforeEach(() => {
global.window.currentScreenId = 1;
// Compteurs de réussite (voir forgeComputeFinalScormStatus) : remis à
@@ -228,27 +271,58 @@ function fakeQuizChoiceBtn() {
return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes };
}
function fakeQuizWidget(objectId, choiceCount) {
const roles = { header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' } };
function fakeQuizWidget(objectId, choiceCount, attrs) {
attrs = attrs || {};
const roles = {
header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' },
score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
};
// Posé désactivé par forgeShowQuizBox dès la question (jamais recréé
// par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir
// les tests dédiés plus bas. Un objet à part (pas dérivé de
// roles.choices.innerHTML, une simple chaîne dans ce mock) pour
// pouvoir observer .disabled/.textContent comme un vrai élément DOM.
const continueBtn = { disabled: true, textContent: 'Continuer →' };
const classes = new Set();
// querySelectorAll('.quizBoxChoiceBtn') n'a pas besoin de refléter le
// HTML réellement injecté dans roles.choices.innerHTML (voir
// forgeShowQuizBox) pour ces tests — juste le bon NOMBRE de boutons,
// marqués correct/wrong par forgeQuizBoxAnswer via ces mêmes objets.
const buttons = Array.from({ length: choiceCount }, () => fakeQuizChoiceBtn());
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) },
const widget = {
parentNode: null,
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
// un widget "posé sur l'écran courant" est toujours, au départ,
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
closest: sel => (sel === '.playScreen' ? { getAttribute: a => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
getAttribute: attr => {
if (attr === 'data-object-id') return String(objectId);
if (attr in attrs) return attrs[attr];
return null;
},
setAttribute: (k, v) => { attrs[k] = v; },
classList: {
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
// (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen).
add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
},
querySelector: sel => {
if (sel === '[data-quiz-role="header"]') return roles.header;
if (sel === '[data-quiz-role="question"]') return roles.question;
if (sel === '[data-quiz-role="choices"]') return roles.choices;
if (sel === '[data-quiz-role="score"]') return roles.score;
if (sel === '[data-quiz-role="timer"]') return roles.timer;
if (sel === '.quizBoxContinueBtn') return continueBtn;
return null;
},
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
offsetWidth: 100,
_roles: roles, _classes: classes, _buttons: buttons,
_roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn,
};
return widget;
}
function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
@@ -260,7 +334,18 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById
return null;
},
querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests
querySelectorAll(sel) {
// aucun widget "🏆 Score" dans ces tests — seul le score AFFICHÉ
// DANS la boîte à quiz elle-même (voir forgeUpdateQuizBoxScoreDisplays)
// a besoin d'être résolu ici, via le rôle déjà exposé par
// fakeQuizWidget ci-dessus.
if (sel === '.quizBoxWidget [data-quiz-role="score"]') return quizWidget ? [quizWidget._roles.score] : [];
return [];
},
// Nécessaire pour un quiz PLEIN ÉCRAN (voir forgeEscapeCameraForFullscreen) :
// pas besoin de simuler un vrai retrait/réinsertion DOM pour ces
// tests, juste que l'appel ne plante pas.
body: { appendChild: () => {} },
};
}
@@ -274,6 +359,197 @@ test.beforeEach(() => {
window.forgeQuizScore = 0;
});
// Bug signalé : "j'ai créé un quiz à l'affichage de l'écran avec l'ia,
// mais quand je fais jouer rien ne s'affiche" — un quiz RH AUTONOME (que
// des questions, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de
// dialogue" posée, seulement d'une "❓ Boîte à quiz" ; forgeShowDialogueBox
// sortait pourtant en silence dès le départ faute de boîte de dialogue,
// empêchant même la première question de s'afficher.
function docWithQuizOnlyNoDialogueBox(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;
return null; // jamais de ".dialogueBoxWidget" sur cet écran
},
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
body: { appendChild: () => {} },
};
}
test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) affiche bien la première question', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
try {
forgeShowDialogueBox('d_30', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
assert.equal(quizWidget._classes.has('is-active'), true);
assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?');
} finally { global.document = previousDoc; }
});
test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (markCompleted inclus)', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
try {
forgeShowDialogueBox('d_31', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
], true); // markCompleted
forgeQuizBoxAnswer(99, 0); // bonne réponse
assert.equal(window.forgeQuizScore, 1);
forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé
assert.equal(window.forgeCompletedDialogueIds['d_31'], true);
assert.equal(quizWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
// ---------- Écran de résultat de fin de quiz ("classique" uniquement,
// voir static/js/play/quiz-box-templates.js::forgeQuizClassiqueOnQuizComplete)
// demande explicite : "la page de resultat est cassee... elle affiche un
// conteneur qui reaffiche le contenu du quiz au lieu du resultat" — le
// vrai moteur n'en avait aucun jusqu'ici. `forgeQuizTemplateEffects` est
// une variable GLOBALE (voir quiz-box-templates.js, jamais requise ici) :
// simulée au cas par cas, jamais définie par défaut — donc TOUS les
// tests ci-dessus (aucun modèle enregistré) continuent de suivre
// l'ancien chemin (masquage simple), inchangé. ----------
function withFakeTemplateEffects(effects, fn) {
const previous = global.forgeQuizTemplateEffects;
global.forgeQuizTemplateEffects = effects;
try { fn(); } finally { global.forgeQuizTemplateEffects = previous; }
}
test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de résultat : reste actif/plein écran, appelle onQuizComplete avec le score DE CE QUIZ', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
let completeCalledWith = null;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
try {
forgeShowDialogueBox('d_50', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question
forgeQuizBoxContinue(99);
assert.equal(quizWidget._classes.has('is-active'), true, 'reste visible pour montrer le résultat, pas masqué');
assert.equal(quizWidget._classes.has('is-fullscreen'), true);
assert.deepEqual(completeCalledWith, { correctCount: 1, totalCount: 1 });
} finally { global.document = previousDoc; }
});
});
test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de résultat, même avec un modèle qui en propose un', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
let completeCalled = false;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => {
try {
forgeShowDialogueBox('d_51', 'Quiz', [
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin
assert.equal(completeCalled, false);
assert.equal(quizWidget._roles.question.textContent, 'Question 2 ?');
} finally { global.document = previousDoc; }
});
});
test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PUIS tente de fermer l\'onglet — demande explicite : quitter carrément, jamais recommencer', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
const previousClose = window.close;
let closeCalled = false;
window.close = () => { closeCalled = true; };
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
try {
forgeShowDialogueBox('d_52', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif
forgeQuizResultQuit(99);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
// window.close() ne ferme RÉELLEMENT que les onglets ouverts par
// script (restriction navigateur, voir le commentaire dans
// dialogue-box-controller.js) — on ne peut donc tester ici que
// "l'appel a bien été tenté", jamais qu'un onglet a vraiment fermé.
assert.equal(closeCalled, true);
} finally { global.document = previousDoc; window.close = previousClose; }
});
});
test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (navigateur qui ne l\'expose pas)', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
const previousClose = window.close;
delete window.close;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
try {
forgeShowDialogueBox('d_54', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99);
assert.doesNotThrow(() => forgeQuizResultQuit(99));
} finally { global.document = previousDoc; window.close = previousClose; }
});
});
test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => {
const previousDoc = global.document;
global.document = { querySelector: () => null };
try {
assert.doesNotThrow(() => forgeQuizResultQuit(999999));
} finally { global.document = previousDoc; }
});
test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
let completeCalledWith = null;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
try {
forgeShowDialogueBox('d_53', 'Quiz', [
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxTimeout(99, true); // encore la question 2 à venir
t.mock.timers.tick(1500);
assert.deepEqual(completeCalledWith, { correctCount: 0, totalCount: 1 });
assert.equal(quizWidget._classes.has('is-active'), true);
} finally { global.document = previousDoc; }
});
});
test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jamais recréé — juste activé par une réponse (demande explicite, comme la maquette manga)', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_60', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); // -> question
// Dès l'affichage de la question, AVANT toute réponse : présent
// dans le balisage des choix ET désactivé.
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxContinue\(99\)/);
assert.equal(quizWidget._continueBtn.disabled, true);
forgeQuizBoxAnswer(99, 1); // bonne réponse (correct_index=1)
// Le MÊME objet, juste activé — jamais un nouveau bouton recréé.
assert.equal(quizWidget._continueBtn.disabled, false);
} finally { global.document = previousDoc; }
});
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
const dialogueWidget = fakeWidget(42);
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
@@ -372,11 +648,120 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la
} finally { global.document = previousDoc; }
});
// ---------- Plein écran + score affiché (demande explicite) ----------
test('forgeShowQuizBox pose ".is-fullscreen" quand data-fullscreen="1", jamais sinon', () => {
const dialogueWidget = fakeWidget(42);
const quizWidgetOn = fakeQuizWidget(99, 2, { 'data-fullscreen': '1', 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidgetOn);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
assert.equal(quizWidgetOn._classes.has('is-fullscreen'), true);
} finally { global.document = previousDoc; }
const quizWidgetOff = fakeQuizWidget(99, 2, { 'data-fullscreen': '0', 'data-timer-mode': 'aucun' });
global.document = docWithDialogueAndQuiz(fakeWidget(42), quizWidgetOff);
try {
forgeShowDialogueBox('d_11', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
assert.equal(quizWidgetOff._classes.has('is-fullscreen'), false);
} finally { global.document = previousDoc; }
});
test('le score affiché DANS la boîte à quiz (data-quiz-role="score") se met à jour à chaque question et bonne réponse', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
assert.equal(quizWidget._roles.score.textContent, 'Score : 0');
forgeQuizBoxAnswer(99, 1); // bonne réponse, +5
assert.equal(quizWidget._roles.score.textContent, 'Score : 5');
} finally { global.document = previousDoc; }
});
// ---------- Minuteur (optionnel, "aucun" par défaut — demande explicite :
// "pouvoir paramétrer un timer pour répondre aux questions") ----------
test('minuteur "par question" expiré : révèle la bonne réponse (aucun bouton marqué faux), puis "Continuer →" fonctionne normalement', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur (10s)
t.mock.timers.tick(10000); // le minuteur expire tout seul
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la bonne réponse (index 1) révélée
assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), false); // aucun clic -> aucun bouton "faux"
assert.equal(window.forgeQuizScore, 0); // comptée fausse, aucun point
assert.equal(window.forgeQuizTotalAnswered, 1);
forgeQuizBoxContinue(99); // "Continuer →" fonctionne normalement après un minuteur expiré
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
} finally { global.document = previousDoc; }
});
test('un minuteur "par question" toujours en cours est arrêté dès qu\'on répond (jamais de double expiration)', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
forgeQuizBoxAnswer(99, 1); // répond avant l'expiration
assert.equal(window.forgeQuizScore, 5);
t.mock.timers.tick(10000); // le minuteur, s'il tournait encore, exploserait ici
assert.equal(window.forgeQuizTotalAnswered, 1); // toujours une seule réponse comptée
} finally { global.document = previousDoc; }
});
test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS termine le quiz tout seul (markCompleted/thenAction inclus)', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_20', 'Le sorcier', QUIZ_LINES, true); // markCompleted=true
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s)
t.mock.timers.tick(5000); // expire : révèle la bonne réponse
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne
t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
} finally { global.document = previousDoc; }
});
test('forgeQuizBoxTimeout n\'a aucun effet si la question est déjà résolue (pas de double révélation)', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
forgeQuizBoxAnswer(99, 1); // déjà répondu
assert.doesNotThrow(() => forgeQuizBoxTimeout(99, false));
assert.equal(window.forgeQuizTotalAnswered, 1); // pas compté deux fois
} 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 };
global.document = {
querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
};
try {
forgeUpdateAllScoreWidgets();
assert.equal(values[0].textContent, 42);
@@ -384,6 +769,22 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
} finally { global.document = previousDoc; }
});
test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS une boîte à quiz plein écran', () => {
// Demande explicite : "lorsque la boîte à quiz prend tout l'écran, le
// score s'affiche dans la boîte à quiz" — voir
// forgeUpdateQuizBoxScoreDisplays, appelée par forgeUpdateAllScoreWidgets.
const quizScoreEl = { textContent: '' };
const previousDoc = global.document;
window.forgeQuizScore = 7;
global.document = {
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
};
try {
forgeUpdateAllScoreWidgets();
assert.equal(quizScoreEl.textContent, 'Score : 7');
} finally { global.document = previousDoc; }
});
// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ----------
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
@@ -500,7 +901,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {} },
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
@@ -527,7 +928,7 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {} },
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
@@ -0,0 +1,225 @@
// Effets visuels par modèle de "❓ Boîte à quiz" (voir static/js/play/
// quiz-box-templates.js, appelé par dialogue-box-controller.js aux
// points d'extension du moteur partagé).
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.document = global.document || {
createElement() { return { style: {}, className: '', appendChild() {} }; },
};
const {
forgeQuizTemplateEffects,
forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
} = require('../quiz-box-templates.js');
test('les 4 modèles sont bien enregistrés ("defaut"/"manga_dialogue" boîte de dialogue + "classique"/"manga" page de quiz)', () => {
assert.deepEqual(
Object.keys(forgeQuizTemplateEffects).sort(),
['classique', 'defaut', 'manga', 'manga_dialogue'],
);
});
test('le registre ne branche aucun effet pour les modèles "boîte de dialogue" ("defaut"/"manga_dialogue" restent proches du modèle de base, décoration CSS uniquement)', () => {
assert.deepEqual(forgeQuizTemplateEffects.defaut, {});
assert.deepEqual(forgeQuizTemplateEffects.manga_dialogue, {});
});
function fakeMangaWidget() {
const header = { textContent: 'PNJ' };
const progress = { innerHTML: '' };
const resultZone = { innerHTML: '' };
const classes = new Set();
return {
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
querySelector(sel) {
if (sel === '[data-quiz-role="header"]') return header;
if (sel === '[data-manga-role="progress"]') return progress;
if (sel === '[data-manga-role="result"]') return resultZone;
return null;
},
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
_header: header, _progress: progress, _resultZone: resultZone, _classes: classes,
};
}
test('forgeQuizMangaOnShow ("manga", onShow) affiche "Question N/M" (uniquement les lignes de type question) et une barre d\'étapes', () => {
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
global.forgeDialogueBoxState = {
7: {
index: 3, // 0:dialogue 1:question 2:dialogue 3:question(courante) 4:question
lines: [
{ type: 'dialogue' },
{ type: 'question' },
{ type: 'dialogue' },
{ type: 'question' },
{ type: 'question' },
],
},
};
const widget = fakeMangaWidget();
forgeQuizMangaOnShow(widget);
assert.equal(widget._header.textContent, 'Question 2/3');
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i><i class="is-current"></i><i class=""></i>');
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
});
test('forgeQuizMangaOnShow ne plante jamais sans widget/état', () => {
assert.doesNotThrow(() => forgeQuizMangaOnShow(null));
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget()));
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget())); // pas de forgeDialogueBoxState
delete global.forgeQuizBoxState;
});
test('forgeQuizMangaOnShow ne touche à rien si le dialogue ne contient aucune question (total === 0)', () => {
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
global.forgeDialogueBoxState = { 7: { index: 0, lines: [{ type: 'dialogue' }] } };
const widget = fakeMangaWidget();
forgeQuizMangaOnShow(widget);
assert.equal(widget._header.textContent, 'PNJ');
assert.equal(widget._progress.innerHTML, '');
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
});
test('forgeQuizMangaOnShow retire "is-showing-result" (défensif — widget réutilisé pour un quiz déclenché à nouveau)', () => {
const widget = fakeMangaWidget();
widget.classList.add('is-showing-result');
forgeQuizMangaOnShow(widget);
assert.equal(widget._classes.has('is-showing-result'), false);
});
test('forgeQuizMangaOnQuizComplete remplit l\'écran de résultat manga (badge 合格/不合格), remplit la barre d\'étapes à 100% et bascule la RACINE du widget en "is-showing-result"', () => {
const widget = fakeMangaWidget();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizMangaOnQuizComplete(widget, { correctCount: 4, totalCount: 5 });
assert.equal(widget._classes.has('is-showing-result'), true);
assert.match(widget._resultZone.innerHTML, /Score final : 4 \/ 5/);
assert.match(widget._resultZone.innerHTML, /mangaResultBadge pass/);
assert.match(widget._resultZone.innerHTML, /合格/);
assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/);
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i>'.repeat(5));
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous le seuil de réussite', () => {
const widget = fakeMangaWidget();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizMangaOnQuizComplete(widget, { correctCount: 1, totalCount: 5 });
assert.match(widget._resultZone.innerHTML, /mangaResultBadge fail/);
assert.match(widget._resultZone.innerHTML, /不合格/);
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => {
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
});
// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow)
// que "manga", juste avec son propre sélecteur de barre d'étapes
// (data-classique-role, jamais data-manga-role).
function fakeClassiqueWidget() {
const header = { textContent: 'PNJ' };
const progress = { innerHTML: '' };
return {
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
querySelector(sel) {
if (sel === '[data-quiz-role="header"]') return header;
if (sel === '[data-classique-role="progress"]') return progress;
return null;
},
_header: header, _progress: progress,
};
}
test('forgeQuizClassiqueOnShow ("classique", onShow) affiche "Question N/M" et une barre d\'étapes, comme "manga"', () => {
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
global.forgeDialogueBoxState = {
7: { index: 1, lines: [{ type: 'question' }, { type: 'question' }] },
};
const widget = fakeClassiqueWidget();
forgeQuizClassiqueOnShow(widget);
assert.equal(widget._header.textContent, 'Question 2/2');
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i><i class="is-current"></i>');
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
});
test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => {
assert.doesNotThrow(() => forgeQuizClassiqueOnShow(null));
assert.doesNotThrow(() => forgeQuizClassiqueOnShow(fakeClassiqueWidget()));
});
// ---------- Écran de résultat ("classique" uniquement) ----------
function fakeClassiqueWidgetForResult() {
const contentClasses = new Set();
const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } };
const resultZone = { innerHTML: '' };
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
return {
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
querySelector(sel) {
if (sel === '.classiqueContent') return content;
if (sel === '[data-classique-role="result"]') return resultZone;
if (sel === '.classiqueHeaderTitle') return headerTitle;
return null;
},
_content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle,
};
}
test('forgeQuizClassiqueOnQuizComplete remplit l\'écran de résultat (score, badge réussi) et bascule .classiqueContent en "is-showing-result"', () => {
const widget = fakeClassiqueWidgetForResult();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 3, totalCount: 4 });
assert.equal(widget._contentClasses.has('is-showing-result'), true);
assert.match(widget._resultZone.innerHTML, /75%/);
assert.match(widget._resultZone.innerHTML, /3 bonnes réponses sur 4/);
assert.match(widget._resultZone.innerHTML, /Formation Sécurité Incendie/);
assert.match(widget._resultZone.innerHTML, /classiqueResultBadge pass/);
assert.match(widget._resultZone.innerHTML, /Module validé/);
assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/);
assert.match(widget._resultZone.innerHTML, /Quitter le quiz/);
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil de réussite', () => {
const widget = fakeClassiqueWidgetForResult();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 2, totalCount: 4 });
assert.match(widget._resultZone.innerHTML, /50%/);
assert.match(widget._resultZone.innerHTML, /classiqueResultBadge fail/);
assert.match(widget._resultZone.innerHTML, /Module non validé — seuil 70%/);
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => {
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
});
test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => {
const widget = fakeClassiqueWidgetForResult();
widget._content.classList.add('is-showing-result');
// fakeClassiqueWidgetForResult n'a pas les rôles attendus par
// forgeQuizProgressOnShow (header/progress) : seul le retrait de la
// classe nous intéresse ici, le reste no-op sans état global posé.
forgeQuizClassiqueOnShow(widget);
assert.equal(widget._contentClasses.has('is-showing-result'), false);
});
+290 -16
View File
@@ -110,16 +110,93 @@ function forgeResetDialogueBoxFooter(widget, objectId) {
// ---------- Boîte à quiz ----------
function forgeQuizBoxWidgetOnCurrentScreen() {
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
if (scoped) return scoped;
// Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir
// forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus
// une fois la première question affichée — retrouvé via l'attribut
// posé au déplacement plutôt que par sa position dans le DOM.
return document.querySelector('.quizBoxWidget[data-owner-screen-id="' + window.currentScreenId + '"]');
}
function forgeQuizBoxWidgetById(objectId) {
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
}
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene"
// (templates/play.html) est centré via `transform:translate(-50%,-50%)`,
// ce qui en fait la référence de positionnement ("containing block") de
// tout descendant `position:fixed` : le plein écran de la boîte à quiz
// ne couvrait donc que CE conteneur (toujours à la taille scene_width/
// scene_height, la "caméra"), jamais le VRAI viewport du navigateur.
// Seul un déplacement hors de ce conteneur (comme static/js/play/
// video-overlay.js le fait déjà pour son overlay) permet à
// `position:fixed` de couvrir tout l'écran pour de vrai. `data-owner-
// screen-id` (posé ici) permet à forgeQuizBoxWidgetOnCurrentScreen ci-
// dessus de le retrouver malgré le déplacement.
function forgeEscapeCameraForFullscreen(widget) {
if (widget.parentNode === document.body) return;
var screenEl = widget.closest('.playScreen');
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
widget.classList.add('quizBoxWidget--detachedForFullscreen');
document.body.appendChild(widget);
}
// Point de sortie UNIQUE de la boîte à quiz (fin d'une question réussie
// vers la ligne suivante, OU fin de tout le dialogue) — y arrêter aussi
// le minuteur/retirer le plein écran ici garantit qu'aucun des deux
// n'est jamais laissé actif par erreur, quel que soit le chemin de
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
function forgeHideQuizBoxOnCurrentScreen() {
var widget = forgeQuizBoxWidgetOnCurrentScreen();
if (widget) widget.classList.remove('is-active');
if (!widget) return;
widget.classList.remove('is-active', 'is-fullscreen');
var objectId = widget.getAttribute('data-object-id');
forgeStopQuizTimer(objectId);
}
// ---------- Minuteur (plein écran, demande explicite : "un timer pour
// répondre aux questions") ----------
// Optionnel — "aucun" (screens/rendering/quiz_box_config.py) est le
// réglage par défaut, jamais imposé. Porté par data-timer-mode/
// data-timer-seconds sur le widget lui-même (voir screens/rendering/
// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de
// payload séparé — cohérent avec le reste de ce moteur (data-* posés au
// rendu, jamais un aller-retour serveur pour du pur affichage).
var forgeQuizTimerState = {};
function forgeStopQuizTimer(objectId) {
var t = forgeQuizTimerState[objectId];
if (t && t.intervalId) clearInterval(t.intervalId);
delete forgeQuizTimerState[objectId];
}
// mode "question" : redémarre à CHAQUE question (reset=true, appelé
// depuis forgeShowQuizBox). mode "quiz" : démarré UNE SEULE FOIS pour
// tout le quiz (reset=false une fois déjà en cours), continue de tourner
// d'une question à l'autre sans se réinitialiser.
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
forgeStopQuizTimer(objectId);
var state = { remaining: seconds };
forgeQuizTimerState[objectId] = state;
var timerEl = widget.querySelector('[data-quiz-role="timer"]');
function render() {
if (!timerEl) return;
var remaining = Math.max(0, state.remaining);
var m = Math.floor(remaining / 60), s = remaining % 60;
timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
timerEl.classList.toggle('is-urgent', remaining <= 5);
}
render();
state.intervalId = setInterval(function () {
state.remaining -= 1;
render();
if (state.remaining <= 0) {
forgeStopQuizTimer(objectId);
onExpire();
}
}, 1000);
}
// ---------- Score ----------
@@ -127,6 +204,17 @@ function forgeHideQuizBoxOnCurrentScreen() {
function forgeUpdateAllScoreWidgets() {
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
forgeUpdateQuizBoxScoreDisplays();
}
// Score affiché DANS la boîte à quiz elle-même (demande explicite :
// "lorsque la boîte à quiz prend tout l'écran, le score s'affiche dans
// la boîte à quiz") — masqué par CSS hors plein écran (voir
// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
function forgeUpdateQuizBoxScoreDisplays() {
var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
for (var i = 0; i < values.length; i++) values[i].textContent = 'Score : ' + window.forgeQuizScore;
}
// Alimente aussi gameData.scoring (voir static/js/play/offline/
@@ -237,9 +325,19 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) {
if (!dialogueId) return;
lines = lines || [];
// Bug corrigé : "j'ai créé un quiz à l'affichage de l'écran, rien ne
// s'affiche" — un dialogue fait UNIQUEMENT de questions (quiz RH
// autonome, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de dialogue"
// posée sur l'écran, seulement d'une "❓ Boîte à quiz" — cette fonction
// sortait pourtant en silence dès le départ s'il n'y avait aucune boîte
// de dialogue, empêchant même la première question de s'afficher. Sans
// widget réel pour porter la clé d'état, `dialogueId` sert de repli
// (unique par action "Déclencher ce dialogue") — une ligne "réplique"
// sans boîte de dialogue posée reste ignorée plus loin (voir
// forgeShowCurrentConversationLine), même logique symétrique qu'une
// question sans boîte à quiz posée (voir forgeShowQuizBox).
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
if (!widget) return;
var objectId = widget.getAttribute('data-object-id');
var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId);
forgeSyncDialogueStartedToScorm();
if (!lines.length) {
// Rien à jouer : applique quand même markCompleted PUIS thenAction
@@ -248,11 +346,11 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
return;
}
forgeDialogueBoxState[objectId] = {
forgeDialogueBoxState[stateKey] = {
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
markCompleted: !!markCompleted, thenAction: thenAction,
};
forgeShowCurrentConversationLine(objectId);
forgeShowCurrentConversationLine(stateKey);
}
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
@@ -308,9 +406,22 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
var widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
var quizObjectId = widget.getAttribute('data-object-id');
var previousState = forgeQuizBoxState[quizObjectId];
forgeQuizBoxState[quizObjectId] = {
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
// Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) :
// ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite
// sans se réinitialiser — ce drapeau survit d'une question à l'autre
// via l'état précédent de CE MÊME quiz.
quizTimerStarted: previousState ? previousState.quizTimerStarted : false,
// Score DE CE QUIZ (jamais window.forgeQuizScore/forgeQuizCorrectAnswered,
// cumulés sur TOUTE la partie, voir plus haut) — survit d'une
// question à l'autre comme quizTimerStarted : sert à afficher "X
// bonnes réponses sur Y" à la fin (voir forgeQuizBoxContinue/
// forgeQuizPrepareResultScreen plus bas).
correctCount: previousState ? previousState.correctCount : 0,
answeredCount: previousState ? previousState.answeredCount : 0,
};
var header = widget.querySelector('[data-quiz-role="header"]');
var questionEl = widget.querySelector('[data-quiz-role="question"]');
@@ -321,6 +432,33 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
choicesEl.innerHTML = line.choices.map(function (choice, i) {
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
}).join('');
// "Continuer →" TOUJOURS présent dès la question (demande explicite :
// "le bouton continuer est toujours visible mais activé seulement
// quand un choix est fait", comme la maquette manga validée) —
// désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais
// recréé à ce moment-là, juste .disabled=false, voir plus bas).
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')" disabled>Continuer →</button>';
}
// "Plein écran" (demande explicite) — reposé à CHAQUE question,
// sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen,
// static/style.css). Échappe d'abord au conteneur de scène transformé
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
var isFullscreen = widget.getAttribute('data-fullscreen') === '1';
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
widget.classList.toggle('is-fullscreen', isFullscreen);
forgeUpdateQuizBoxScoreDisplays();
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
// imposé, voir screens/rendering/quiz_box_config.py).
var timerMode = widget.getAttribute('data-timer-mode');
var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
if (timerMode === 'question') {
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); });
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); });
} else if (timerMode !== 'quiz') {
forgeStopQuizTimer(quizObjectId);
}
// Rejoue l'animation d'entrée (@keyframes quizPopIn) même si la boîte
// était déjà "is-active" juste avant (2 questions à la suite) : sans
@@ -329,6 +467,18 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
widget.classList.remove('is-active');
void widget.offsetWidth;
widget.classList.add('is-active');
// Effet d'entrée propre au modèle (voir static/js/play/
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
var showFx = forgeQuizTemplateEffectsFor(widget).onShow;
if (showFx) showFx(widget);
}
// Registre d'effets par modèle (voir static/js/play/quiz-box-
// templates.js, chargé AVANT ce fichier) — absent en environnement de
// test qui ne charge pas ce script, d'où la garde `typeof`.
function forgeQuizTemplateEffectsFor(widget) {
var template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
}
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
@@ -346,6 +496,12 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
state.resolved = true;
var widget = forgeQuizBoxWidgetById(quizObjectId);
var correct = choiceIndex === state.correctIndex;
// Minuteur "par question" : cette question est résolue (bonne réponse,
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
// "pour tout le quiz", qui doit continuer à tourner sans interruption
// d'une question à l'autre.
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
if (widget) {
var buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
for (var i = 0; i < buttons.length; i++) {
@@ -355,9 +511,15 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
}
}
window.forgeQuizTotalAnswered += 1;
state.answeredCount += 1;
var fx = forgeQuizTemplateEffectsFor(widget);
if (correct) {
window.forgeQuizCorrectAnswered += 1;
state.correctCount += 1;
forgeAddQuizScore(state.rewardAmount);
if (fx.onCorrect) fx.onCorrect(widget);
} else if (fx.onWrong) {
fx.onWrong(widget);
}
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
@@ -371,26 +533,133 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
// lent ou un utilisateur de lecteur d'écran — l'avancée dépend
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
// minuteur. Le bouton est ajouté APRÈS les choix (jamais en remplaçant
// choicesEl.innerHTML, qui effacerait le surlignage vert/rouge déjà posé).
// minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox
// ci-dessus, TOUJOURS visible — demande explicite, comme la maquette
// manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
// choix juste au-dessus).
if (widget) {
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
if (choicesEl) {
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')">Continuer →</button>';
}
var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
}
}
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
// quiz-box-templates.js::onQuizComplete, ex. forgeQuizClassiqueOnQuizComplete)
// et renvoie une fonction à appeler APRÈS forgeAdvanceConversation (qui
// masque sinon le widget, voir forgeHideQuizBoxOnCurrentScreen) — null
// si ce n'est pas la fin du quiz (forceEnd=false : encore des lignes
// après) ou si le modèle actif n'a aucun écran de résultat (seul
// "classique" en a un pour l'instant, "manga"/les modèles "boîte"
// gardent leur comportement d'avant, inchangé : la boîte se masque
// simplement). `forceEnd` (true) : le minuteur du quiz ENTIER vient
// d'expirer (forgeQuizBoxTimeout) — le quiz se termine MÊME s'il
// restait des questions, jamais une simple question de position dans
// les lignes.
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
if (!widget) return null;
var dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
if (!dialogueState) return null;
var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
if (!isEnding) return null;
var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
if (!completeFx) return null;
var correctCount = state.correctCount;
var answeredCount = state.answeredCount;
return function () {
widget.classList.add('is-active');
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
completeFx(widget, { correctCount: correctCount, totalCount: answeredCount });
};
}
// Bouton de l'écran de résultat (voir forgeQuizPrepareResultScreen
// ci-dessus) — demande explicite : QUITTER carrément (fermer l'onglet/
// la page du navigateur), jamais recommencer le quiz. Le dialogue est
// déjà épuisé (forgeAdvanceConversation l'a fait avant d'afficher ce
// résultat) — masque quand même le widget D'ABORD (repli si la fermeture
// est refusée, voir ci-dessous), avant de tenter de fermer. "is-showing-
// result" est retirée des DEUX emplacements possibles ("manga" la pose
// sur la racine du widget, "classique" sur .classiqueContent, voir
// static/js/play/quiz-box-templates.js) — l'un des deux est toujours un
// no-op inoffensif.
//
// window.close() : un navigateur ne ferme RÉELLEMENT que les onglets
// ouverts PAR SCRIPT (window.open) ou la page auto-générée d'un
// téléchargement — jamais un onglet atteint par une navigation normale
// (tapée, cliquée, ou un lien) : restriction de sécurité du navigateur
// lui-même, aucun moyen de la contourner en JS. window.close() échoue
// alors silencieusement (aucune erreur, aucun retour) : le repli
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
// courant en pratique.
function forgeQuizResultQuit(quizObjectId) {
var widget = forgeQuizBoxWidgetById(quizObjectId);
if (widget) {
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
var content = widget.querySelector('.classiqueContent');
if (content) content.classList.remove('is-showing-result');
}
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
}
// Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer
// ci-dessus — masque la boîte à quiz et avance la conversation, jamais
// ci-dessus — masque la boîte à quiz et avance la conversation (SAUF à
// la toute dernière question d'un modèle qui sait afficher un écran de
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
// automatique (voir le commentaire ci-dessus).
function forgeQuizBoxContinue(quizObjectId) {
var state = forgeQuizBoxState[quizObjectId];
if (!state) return;
var widget = forgeQuizBoxWidgetById(quizObjectId);
if (widget) widget.classList.remove('is-active');
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
if (widget && !showResultScreen) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(state.dialogueObjectId);
if (showResultScreen) showResultScreen();
}
// Minuteur expiré (demande explicite : "pouvoir paramétrer un timer pour
// répondre aux questions", jamais imposé — "aucun" reste le réglage par
// défaut) : mode "question" — cette question est comptée fausse (0
// point), la bonne réponse s'affiche, puis "Continuer →" fonctionne
// normalement. Mode "quiz" (wholeQuizExpired) — même révélation, mais
// plus AUCUN temps pour la suite : le quiz se termine tout seul après un
// court délai de lecture plutôt que d'attendre un clic. Réutilise
// EXACTEMENT le chemin d'une mauvaise réponse (forgeQuizBoxAnswer) avec
// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse
// sans en marquer une "fausse"), jamais de code dupliqué.
function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
var state = forgeQuizBoxState[quizObjectId];
if (!state || state.resolved) return;
forgeQuizBoxAnswer(quizObjectId, -1);
if (!wholeQuizExpired) return;
var widget = forgeQuizBoxWidgetById(quizObjectId);
// Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER
// est écoulé — désactivé/relibellé pour éviter toute course avec la
// fin automatique programmée juste en dessous.
if (widget) {
var continueBtn = widget.querySelector('.quizBoxContinueBtn');
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
}
setTimeout(function () {
var s = forgeQuizBoxState[quizObjectId];
if (!s) return; // déjà fermé entre-temps
// forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non
// des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT
// que forgeAdvanceConversation ne supprime forgeDialogueBoxState.
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen');
delete forgeQuizBoxState[quizObjectId];
// Force l'épuisement des lignes restantes — même sortie que la fin
// naturelle d'un dialogue (markCompleted/thenAction inclus, voir
// forgeAdvanceConversation), sans dupliquer cette logique.
var dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
if (dialogueState) {
dialogueState.index = dialogueState.lines.length;
forgeAdvanceConversation(s.dialogueObjectId);
}
if (showResultScreen) showResultScreen();
}, 1500);
}
// static/js/play/__tests__/ (node:test) — même garde que les autres
@@ -400,9 +669,14 @@ if (typeof module !== 'undefined' && module.exports) {
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeResetDialogueBoxFooter,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout,
forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen,
forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore,
forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus,
forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState,
forgeEscapeCameraForFullscreen,
forgeQuizPrepareResultScreen, forgeQuizResultQuit,
};
}
+165
View File
@@ -0,0 +1,165 @@
// ---------- Effets visuels par modèle de "❓ Boîte à quiz" ----------
// Registre appelé par static/js/play/dialogue-box-controller.js (JAMAIS
// l'inverse) aux points d'extension du moteur PARTAGÉ (forgeShowQuizBox,
// forgeQuizBoxAnswer) — voir screens/rendering/quiz_box_templates.py
// pour le HTML/CSS de chaque modèle. Ajouter un futur modèle n'oblige
// donc jamais à toucher dialogue-box-controller.js : juste une nouvelle
// entrée ici (optionnelle) + son CSS/HTML.
// Effet PARTAGÉ entre plusieurs modèles "page de quiz" (Manga, Classique,
// et tout futur modèle qui voudrait la même mécanique) — demande
// explicite : au-dessus de la question, "Question N/M" (nombre de
// QUESTIONS de ce dialogue, pas la position brute dans lines qui mélange
// aussi les répliques) plutôt que le nom de l'objet déclencheur — et une
// barre d'étapes juste en dessous du bandeau. `forgeQuizBoxState`/
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
// chargé à côté de ce fichier) sont des variables GLOBALES de script
// classique (pas de module), lisibles ici telles quelles.
// `progressSelector` : chaque modèle a son propre conteneur de barre
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
// décoration propre à CHAQUE modèle, pas du contrat partagé).
function forgeQuizProgressOnShow(widget, progressSelector) {
if (!widget) return;
var quizObjectId = widget.getAttribute('data-object-id');
var quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
if (!quizState) return;
var dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
if (!dialogueState) return;
function isQuestion(line) { return line && line.type === 'question'; }
var total = dialogueState.lines.filter(isQuestion).length;
if (total === 0) return;
var current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
var header = widget.querySelector('[data-quiz-role="header"]');
if (header) header.textContent = 'Question ' + current + '/' + total;
var progressEl = widget.querySelector(progressSelector);
if (progressEl) {
var bars = [];
for (var i = 1; i <= total; i++) {
bars.push('<i class="' + (i < current ? 'is-done' : (i === current ? 'is-current' : '')) + '"></i>');
}
progressEl.innerHTML = bars.join('');
}
}
function forgeQuizMangaOnShow(widget) {
// Défensif : si CE widget est réutilisé pour un quiz déclenché à
// nouveau plus tard dans la partie (voir forgeQuizResultQuit,
// dialogue-box-controller.js — le widget lui-même n'est jamais
// reconstruit, juste masqué), une question fraîchement affichée ne
// doit jamais rester sur l'écran de résultat de la fois précédente.
if (widget) widget.classList.remove('is-showing-result');
forgeQuizProgressOnShow(widget, '[data-manga-role="progress"]');
}
function forgeQuizClassiqueOnShow(widget) {
// Même garde-fou que forgeQuizMangaOnShow ci-dessus, ici posée sur
// .classiqueContent (pas la racine du widget, voir
// render_quiz_box_page_classique) plutôt que sur le widget lui-même.
if (widget) {
var content = widget.querySelector('.classiqueContent');
if (content) content.classList.remove('is-showing-result');
}
forgeQuizProgressOnShow(widget, '[data-classique-role="progress"]');
}
// Écrans de résultat de fin de quiz ("classique" ci-dessous, "manga"
// plus bas) — appelés par forgeQuizPrepareResultScreen
// (dialogue-box-controller.js) UNIQUEMENT à la toute dernière question
// (ou si le minuteur du quiz ENTIER expire). `result.correctCount`/
// `result.totalCount` : comptés PAR CE QUIZ (voir forgeQuizBoxState dans
// dialogue-box-controller.js), jamais window.forgeQuizScore/
// forgeQuizCorrectAnswered qui cumulent TOUTE la partie. Le seuil de
// réussite réutilise EXACTEMENT window.FORGE_SUCCESS_THRESHOLD_PERCENT
// (déjà utilisé pour le statut SCORM final, voir
// forgeComputeFinalScormStatus) plutôt qu'un réglage à part. Le bouton
// QUITTE le quiz (jamais ne le recommence, demande explicite) — voir
// forgeQuizResultQuit, dialogue-box-controller.js.
function forgeQuizClassiqueOnQuizComplete(widget, result) {
if (!widget) return;
var content = widget.querySelector('.classiqueContent');
var zone = widget.querySelector('[data-classique-role="result"]');
if (!content || !zone) return;
var total = (result && result.totalCount) || 0;
var correct = (result && result.correctCount) || 0;
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
var passed = pct >= threshold;
var titleEl = widget.querySelector('.classiqueHeaderTitle');
var title = titleEl ? titleEl.textContent : 'Quiz';
var quizObjectId = widget.getAttribute('data-object-id');
zone.innerHTML =
'<div class="classiqueResultRing" style="--pct:' + pct + '"><span>' + pct + '%</span></div>' +
'<p class="classiqueResultTitle">' + correct + ' bonne' + (correct > 1 ? 's' : '') + ' réponse' + (correct > 1 ? 's' : '') + ' sur ' + total + '</p>' +
'<p class="classiqueResultSub">« ' + (typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title) + ' »</p>' +
'<div class="classiqueResultBadge ' + (passed ? 'pass' : 'fail') + '">' +
(passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') +
'</div>' +
'<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">✕ Quitter le quiz</button>';
content.classList.add('is-showing-result');
}
// "Manga" — même mécanique que forgeQuizClassiqueOnQuizComplete
// ci-dessus, thème manga propre à ce modèle (voir static/style.css) :
// pas de conteneur "content" à part ici (contrairement à
// .classiqueContent), la classe "is-showing-result" bascule sur la
// RACINE du widget elle-même (voir render_quiz_box_page_manga). La
// barre d'étapes est reposée à 100% "terminé" (tous les segments
// is-done) : forgeQuizProgressOnShow ne le fait pas toute seule, sa
// dernière question reste "is-current" (jamais "is-done", il n'y a
// plus de question suivante vers laquelle avancer) — un quiz FINI doit
// visuellement se lire comme entièrement complété.
function forgeQuizMangaOnQuizComplete(widget, result) {
if (!widget) return;
var zone = widget.querySelector('[data-manga-role="result"]');
if (!zone) return;
var total = (result && result.totalCount) || 0;
var correct = (result && result.correctCount) || 0;
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
var passed = pct >= threshold;
var quizObjectId = widget.getAttribute('data-object-id');
zone.innerHTML =
'<div class="mangaResultCard">' +
'<span class="mangaResultTitle">Fin du quiz</span>' +
'<p class="mangaResultScore">Score final : ' + correct + ' / ' + total + '</p>' +
'<div class="mangaResultBadge ' + (passed ? 'pass' : 'fail') + '">' + (passed ? '合格' : '不合格') + '</div>' +
'<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">Quitter le quiz</button></div>' +
'</div>';
var progressEl = widget.querySelector('[data-manga-role="progress"]');
if (progressEl && total > 0) {
var bars = [];
for (var i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
progressEl.innerHTML = bars.join('');
}
widget.classList.add('is-showing-result');
}
var forgeQuizTemplateEffects = {
// Modèles "boîte de dialogue" (voir screens/rendering/quiz_box_config.py
// ::QUIZ_BOX_DIALOG_TEMPLATES) — "Verre dépoli"/"Néon"/"Minimal"/
// "Ludique" ont été retirés (demande explicite : "supprime tous les
// modele de boite sauf le base"). "manga_dialogue" (clé DIFFÉRENTE du
// modèle "page" "manga" ci-dessous, même registre partagé) reste
// proche de "defaut" — aucun effet JS particulier, juste sa propre
// décoration CSS (voir render_quiz_box_manga_dialogue).
defaut: {},
manga_dialogue: {},
// Modèles "page de quiz" (QUIZ_BOX_PAGE_TEMPLATES) — le tampon "正解"/
// "不正解" du modèle "manga" est posé en CSS pur (::after sur
// .is-correct/.is-wrong, voir static/style.css), seul "Question N/M" +
// la barre d'étapes ont besoin d'un vrai calcul JS (onShow ci-dessus).
manga: { onShow: forgeQuizMangaOnShow, onQuizComplete: forgeQuizMangaOnQuizComplete },
classique: { onShow: forgeQuizClassiqueOnShow, onQuizComplete: forgeQuizClassiqueOnQuizComplete },
};
// static/js/play/__tests__/ (node:test) — même garde que le reste de
// static/js/play/.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeQuizTemplateEffects,
forgeQuizProgressOnShow, forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
};
}
+74
View File
@@ -796,6 +796,80 @@ function saveDialogueBoxStyle(objectId) {
});
}
// "🖼️ Modèles" (voir templates/scene_edit.html, "❓ Boîte à quiz") — DEUX
// galeries INDÉPENDANTES (demande explicite : "boîte de dialogue" hors
// plein écran, "page de quiz" en plein écran, jamais confondues) — une
// SEULE des deux est rendue à la fois (voir quiz_box_config.fullscreen
// côté serveur), chacune surligne la vignette cliquée sur sa propre
// grille (data-selected-template) puis sauvegarde tout de suite (même
// geste qu'un clic sur une tuile de personnage/décor).
function _selectQuizBoxTemplate(objectId, key, gridId) {
var grid = document.getElementById(gridId);
if (grid) {
grid.setAttribute('data-selected-template', key);
var tiles = grid.querySelectorAll('.quizTemplateTile');
for (var i = 0; i < tiles.length; i++) {
tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key);
}
}
saveQuizBoxConfig(objectId);
}
function selectQuizBoxDialogTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizDialogTemplateGrid'); }
function selectQuizBoxPageTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizPageTemplateGrid'); }
// "⚙️ Options du quiz" + "🖼️ Modèles" (demande explicite : quiz RH
// autonome — plein écran/minuteur/DEUX modèles indépendants) — mirror de
// saveDialogueBoxStyle, mais un objet de config à PART (voir
// screens/rendering/quiz_box_config.py), jamais mélangé à "🎨 Style"
// (couleurs/police, inchangé, voir plus haut). Remplace l'aperçu par le
// HTML fraîchement rendu une fois sauvegardé — un modèle change la
// STRUCTURE du balisage (voir screens/rendering/quiz_box_templates.py),
// pas juste une classe CSS : deviner quelle balise ajouter/retirer côté
// client casserait la plupart des modèles.
//
// dialog_template/page_template : SEULE une des deux grilles est rendue
// à la fois (voir templates/scene_edit.html) — celle qui est ABSENTE
// garde sa valeur via #quizBoxTemplateMeta (posé par le serveur), jamais
// écrasée par un défaut.
//
// "Plein écran" (demande explicite : "elle doit occuper tout l'écran",
// "être visible par défaut", "la notion de caméra disparaît" — DANS CET
// ÉDITEUR aussi, pas seulement au jeu) : rechargement complet dès que ce
// réglage CHANGE de valeur — le cadre "🎥 Champ de la caméra" dépend de
// TOUS les quiz de l'écran (pas seulement celui-ci, voir
// routes/scenes/scene_edit_view.py::any_quiz_box_fullscreen), ET la
// galerie de modèles à afficher change entièrement (boîte <-> page) :
// un simple patch DOM ne pourrait recalculer ni l'un ni l'autre ici.
function saveQuizBoxConfig(objectId) {
var meta = document.getElementById('quizBoxTemplateMeta');
function currentTemplate(gridId, metaAttr) {
var grid = document.getElementById(gridId);
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
var selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
return grid.getAttribute('data-selected-template') || (selectedTile && selectedTile.getAttribute('data-template'));
}
var fullscreenEl = document.getElementById('quiz-fullscreen');
var fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1');
var config = {
fullscreen: fullscreenEl.checked,
timer_mode: document.getElementById('quiz-timer-mode').value,
timer_seconds: Number(document.getElementById('quiz-timer-seconds').value),
dialog_template: currentTemplate('quizDialogTemplateGrid', 'data-dialog-template') || 'defaut',
page_template: currentTemplate('quizPageTemplateGrid', 'data-page-template') || 'manga',
};
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/quiz-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: config }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (fullscreenChanged) { window.location.reload(); return; }
var el = document.getElementById('el-' + objectId);
var widget = el && el.querySelector('.quizBoxWidget');
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
});
}
function initPersonnageCommandsPanel() {
var body = document.getElementById('personnageCommandsBody');
if (!body) return;