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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+559 -2
View File
@@ -609,6 +609,24 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.iconGallery.personnageGallery{ grid-template-columns:repeat(2, 1fr); gap:10px; max-height:420px; }
.personnageThumb{ width:100%; height:100%; object-fit:contain; image-rendering:pixelated; }
/* "🖼️ Modèles" de la "❓ Boîte à quiz" (dialogue ET page, voir
templates/scene_edit.html, screens/rendering/quiz_box_templates.py) —
chaque modèle est un thème complet et autonome (structure/police/
couleurs propres) : plutôt que reproduire sa structure/CSS en
miniature (fragile, vite désynchronisé d'un vrai thème complet), la
vignette est une vraie capture d'écran du modèle (static/
quiz-templates/<clé>.png, une par modèle). */
.quizTemplateGrid{ display:grid; grid-template-columns:repeat(2, 1fr); gap:10px; margin-top:6px; }
.quizTemplateTile{
display:flex; flex-direction:column; gap:6px; padding:6px; background:var(--panel2);
border:2px solid var(--border); border-radius:8px; cursor:pointer;
}
.quizTemplateTile:hover{ border-color:var(--accent); }
.quizTemplateTile.is-selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent) inset; }
.quizTemplateName{ font-size:11.5px; color:var(--text-dim); text-align:center; }
.quizTemplatePreviewImg{ display:block; width:100%; height:170px; overflow:hidden; border-radius:4px; }
.quizTemplatePreviewImg img{ width:100%; height:100%; object-fit:cover; object-position:top; display:block; }
/* "Mes assets" — sous-sections Images/Sons/Vidéos (demande explicite :
"voir, utiliser ou supprimer des son, image et vidéo") : une tuile
image garde son bouton de pose (clic/glisser-déposer, voir
@@ -795,6 +813,27 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
}
.playScreen .quizBoxWidget{ display:none; }
.playScreen .quizBoxWidget.is-active{ display:flex; animation:quizPopIn .3s ease; }
/* Un quiz PLEIN ÉCRAN est déplacé en enfant direct de <body> au premier
affichage (voir forgeEscapeCameraForFullscreen, dialogue-box-controller.js
— ".playScreen.playScene" est transformé/centré, ce qui casserait
position:fixed s'il y restait) : plus dans ".playScreen" ensuite, donc
plus couvert par les deux règles ci-dessus — cette classe reproduit le
même masquage par défaut/affichage sur ".is-active", posée au moment du
déplacement. */
.quizBoxWidget--detachedForFullscreen{ display:none; }
.quizBoxWidget--detachedForFullscreen.is-active{ display:flex; animation:quizPopIn .3s ease; }
/* Bandeau partagé header + score/minuteur (demande explicite : "le score
doit être à l'intérieur de la boîte à quiz, pas à l'extérieur") — le
score (topbar) vit dans un élément FRÈRE du header (voir le
commentaire d'en-tête de screens/rendering/quiz_box_templates.py, JS
écrase les enfants du header via .textContent), posé ici CÔTE À CÔTE
avec lui dans le MÊME bandeau plutôt qu'empilé au-dessus dans une bande
à part — un seul fond commun (posé sur CE conteneur, jamais sur le
header seul, voir _quiz_header_row_html), pas deux bandes de couleurs
différentes. */
.quizBoxHeaderRow{ display:flex; align-items:stretch; }
.quizBoxHeaderRow .quizBoxHeader{ flex:1 1 auto; min-width:0; }
.quizBoxHeaderRow .quizBoxTopBar{ width:auto; flex:0 0 auto; }
.quizBoxHeader{ padding:8px 12px; font-weight:700; }
.quizBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:10px; }
.quizBoxQuestionText{ margin:0; font-weight:600; }
@@ -818,12 +857,530 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.quizBoxChoiceBtn.is-wrong{ background:#e0524d !important; border-color:#e0524d !important; animation:quizWrongShake .4s ease; }
/* Bouton "Continuer →" (voir forgeQuizBoxAnswer/forgeQuizBoxContinue,
RGAA/WCAG 2.2.1) — remplace l'ancienne avancée automatique après un
délai fixe : l'apprenant décide lui-même quand passer à la suite. */
délai fixe : l'apprenant décide lui-même quand passer à la suite.
TOUJOURS présent dès la question (posé désactivé par forgeShowQuizBox,
activé par forgeQuizBoxAnswer une fois répondu — demande explicite,
comme la maquette manga validée) : ":disabled" générique ici, commun
à TOUS les modèles, quelle que soit leur propre couleur d'action. */
.quizBoxContinueBtn{
margin-top:4px; background:#2f6fed; color:#fff; border:1px solid #2f6fed;
border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; font-weight:600; cursor:pointer;
}
.quizBoxContinueBtn:hover{ background:#255bc4; }
.quizBoxContinueBtn:hover:not(:disabled){ background:#255bc4; }
.quizBoxContinueBtn:disabled{ opacity:.45; cursor:not-allowed; }
/* "Plein écran" (demande explicite : quiz RH autonome, voir
screens/rendering/quiz_box_config.py) — la boîte à quiz elle-même
devient la surface plein écran (pas besoin d'un overlay séparé comme
static/js/play/video-overlay.js : son propre fond couvre déjà tout).
z-index 1001 : au-dessus de .forgeVideoOverlay (1000) par précaution,
au cas où une vidéo serait encore visible. Commun à tous les modèles. */
.quizBoxWidget.is-fullscreen{
position:fixed !important; inset:0 !important; left:0 !important; top:0 !important;
width:100vw !important; height:100vh !important;
z-index:1001 !important; display:flex !important; flex-direction:column !important;
}
/* Demande explicite : "pas de bordure visible, pas de bord arrondi" en
plein écran — une bordure/un coin arrondi/une ombre portée n'ont plus
de sens une fois que la boîte occupe TOUT l'écran (rien derrière/autour
d'elle à en distinguer), quel que soit le modèle. */
.quizBoxWidget.is-fullscreen{
border-radius:0 !important; box-shadow:none !important; border:none !important;
}
/* Bug corrigé : "rien ne s'affiche" — align-items:center/justify-content:center
posés ici auparavant retiraient l'étirement (stretch) par défaut d'un
conteneur flex en colonne, réduisant .quizBoxHeader/.quizBoxBody à la
largeur de LEUR PROPRE contenu au lieu de toute la largeur — dans
certains navigateurs, combiné à .quizBoxBody (lui-même un conteneur
flex en colonne, voir plus haut), ce rétrécissement en cascade
ramenait la zone de texte à une largeur quasi nulle, rendant le
header/la question/les choix invisibles bien qu'ils soient bien posés
dans le DOM avec leur texte. Un simple empilement plein largeur/pleine
hauteur (comportement déjà éprouvé hors plein écran) suffit : pas
besoin de centrer quoi que ce soit, la boîte occupe déjà tout l'écran.*/
.quizBoxWidget.is-fullscreen .quizBoxBody{ min-height:0; }
/* Éditeur (voir templates/scene_edit.html) : occuper le VIEWPORT du
navigateur (comme au jeu) casserait l'édition — un fond opaque
couvrirait aussi le panneau de propriétés. "Plein écran" occupe ici
tout le CANEVAS (le "monde" de la scène) à la place, posé sur
.canvasElement (voir plus bas, jamais sur .quizBoxWidget lui-même —
déjà forcé à 100%/100% de .canvasElement par .canvasElementInner >
.quizBoxWidget, voir plus haut dans ce fichier) : suffisant pour que
le créateur voie tout de suite l'effet, sans avoir à lancer le jeu.
La "notion de caméra disparaît" (demande explicite) : le cadre "🎥
Champ de la caméra" est masqué côté serveur dès qu'un quiz plein écran
existe sur l'écran (voir routes/scenes/scene_edit_view.py), puisqu'il
n'a plus aucun sens une fois que ce widget couvre tout, caméra ou pas.*/
.canvasElement.isQuizFullscreenPreview{
left:0 !important; top:0 !important; width:100% !important; height:100% !important; z-index:9000 !important;
}
/* Barre score/minuteur — visible SEULEMENT en plein écran (une boîte à
quiz "normale", posée dans le décor, n'a pas besoin d'afficher un
score/minuteur en permanence) : masquée par défaut, quel que soit le
modèle, pour ne rien changer à l'apparence d'un quiz existant. */
.quizBoxTopBar{
display:none; width:100%; padding:6px 10px; font-weight:700; font-size:13px;
align-items:center; justify-content:space-between; gap:10px; box-sizing:border-box;
}
.quizBoxWidget.is-fullscreen .quizBoxTopBar{ display:flex; }
.quizBoxTimerDisplay:empty{ display:none; }
.quizBoxTimerDisplay{
font-variant-numeric:tabular-nums; padding:2px 10px; border-radius:999px;
background:rgba(255,255,255,.15);
}
.quizBoxTimerDisplay.is-urgent{ background:#e0524d; color:#fff; }
/* ---------- Modèle "Manga" (boîte de dialogue) — voir
screens/rendering/quiz_box_templates.py::render_quiz_box_manga_dialogue,
validé par le créateur via une maquette interactive. Pendant, en
petite carte flottante (JAMAIS plein écran), du modèle "page de quiz"
"Manga" plus bas — réutilise la structure PARTAGÉE des modèles
"boîte" (quizBoxHeaderRow/quizBoxHeader/quizBoxTopBar/quizBoxBody/
quizBoxQuestionText/quizBoxChoices/quizBoxChoiceBtn/
quizBoxContinueBtn), seule la décoration change. .quizMangaDialogueFrame
porte la forme/le fond (jamais le conteneur racine, qui doit rester
positionnable/redimensionnable normalement) — même principe que les
anciens modèles "Verre dépoli"/"Néon". Police : Montserrat/Poppins
(déjà auto-hébergées, voir static/vendor/fonts/fonts.css) — jamais
Bangers/Noto Sans JP de la maquette (Google Fonts), même contrainte
SCORM hors-ligne déjà rencontrée pour le modèle "page" "Manga". */
.quizBoxWidget--mangaDialogue{
position:relative; background:#151316; border-radius:0 !important;
box-shadow:6px 6px 0 rgba(0,0,0,.4) !important; border:3px solid #000 !important;
}
.quizBoxWidget--mangaDialogue .quizMangaDialogueFrame{
display:flex; flex-direction:column; flex:1; width:100%; height:100%; overflow:hidden;
font-family:"Poppins", system-ui, sans-serif; color:#f3ead6; background:#151316;
}
.quizBoxWidget--mangaDialogue .quizBoxHeaderRow{
/* Identité de marque FIXE (rouge), jamais la couleur configurable du
panneau "🎨 Style" (header_bg, posée en style INLINE par
_quiz_header_row_html — !important nécessaire pour la dominer) —
même principe déjà appliqué par l'ancien modèle "Ludique" avant sa
suppression, conforme à la maquette validée. */
background-color:#d81e3e !important;
clip-path:polygon(0 0, 100% 0, 97% 100%, 0 100%); padding:10px 20px 12px 18px; position:relative;
background-image:radial-gradient(rgba(255,255,255,.16) 1px, transparent 1.4px); background-size:7px 7px;
/* Un `border` classique ne suit pas un clip-path — le liseré noir le
long du bord dentelé (demande explicite : "des bords stylisés" sur
la maquette) vient d'un drop-shadow qui, lui, épouse la silhouette
RÉELLEMENT visible après découpe (contrairement à box-shadow, qui
resterait un simple rectangle). */
filter:drop-shadow(2px 0 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow(0 2px 0 #000);
}
.quizBoxWidget--mangaDialogue .quizBoxHeader{
font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; letter-spacing:.02em;
text-transform:uppercase; font-size:15px; color:#fff2ec; text-shadow:2px 2px 0 #8f0f24;
}
/* "Quête : " devant le nom (JS n'écrit QUE le nom brut de l'objet
déclencheur dans [data-quiz-role="header"], voir forgeShowQuizBox,
dialogue-box-controller.js — contrat partagé par tous les modèles
"boîte") — un ::before CSS pur, jamais touché par ce .textContent,
pour reproduire le "Quête : X" de la maquette validée. */
.quizBoxWidget--mangaDialogue .quizBoxHeader::before{ content:"Quête : "; }
.quizBoxWidget--mangaDialogue .quizBoxTopBar{ margin-top:6px; }
.quizBoxWidget--mangaDialogue .quizBoxScoreDisplay,
.quizBoxWidget--mangaDialogue .quizBoxTimerDisplay{ background:rgba(0,0,0,.28); color:#fff; border-radius:2px; }
/* Corps CRÈME (papier), comme la maquette — jamais la couleur
configurable du panneau "🎨 Style" (body_bg, posée en style INLINE) :
!important nécessaire pour la dominer, même raison que le bandeau
ci-dessus. padding-bottom réservé pour ne jamais passer SOUS le
bandeau de pied de page toujours visible (voir
.quizMangaDialogueFooterBand plus bas).
Coin dentelé du bas (demande explicite) SANS clip-path ni décor à
part : un `background` (dégradé quasi-plat, juste 3° d'inclinaison,
qui BASCULE net de crème à encre) — deux essais précédents (clip-path
sur .quizBoxBody, puis un décor z-index:-1 séparé) ont chacun rogné
ou fait disparaître le bouton "Continuer →" du moteur partagé
(DESCENDANT de cet élément, positionné en absolute par rapport au
widget) : un clip-path/overflow sur un ANCÊTRE le rogne TOUJOURS,
quel que soit son propre positionnement ; un contexte d'empilement
(position+z-index) posé ici l'emprisonne aussi, l'empêchant de
ressortir au-dessus du bandeau bas. Un simple `background` ne clippe
ni n'empile RIEN — le bouton reste entièrement libre. */
.quizBoxWidget--mangaDialogue .quizBoxBody{
/* Fond CRÈME plat (demande explicite : coins bas arrondis) — le
dégradé diagonal précédent aurait rendu l'arrondi invisible : ses
18% du bas étaient DÉJÀ encre, un coin arrondi encre sur un fond
encre (le bandeau du bas) ne se voit tout simplement pas, aucun
contraste. Coin plat + arrondi net = le seul réglage qui reste
visible. */
background:#f3ead6 !important;
color:#151316; padding:16px 18px 78px; overflow:visible;
/* Un `border-radius` seul, SANS toucher à overflow (reste visible),
ne clippe RIEN : contrairement à un clip-path ou un
overflow:hidden, il ne rogne le bouton "Continuer →" (descendant
qui doit rester libre de déborder, voir le commentaire au-dessus)
que si overflow est masqué — jamais le cas ici. */
border-bottom-left-radius:16px; border-bottom-right-radius:16px;
}
.quizBoxWidget--mangaDialogue .quizBoxChoices{ overflow:visible; }
.quizBoxWidget--mangaDialogue .quizBoxQuestionText{ font-family:"Montserrat", sans-serif; font-weight:700; color:#151316; }
.quizBoxWidget--mangaDialogue .quizBoxChoices{ gap:8px; counter-reset:mangaDialogueChoice; }
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn{
counter-increment:mangaDialogueChoice; position:relative; background:#fff; color:#151316;
border:2px solid #000; border-radius:0; padding:8px 10px 8px 38px; font-family:inherit;
}
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn::before{
content:counter(mangaDialogueChoice, upper-alpha); position:absolute; left:8px; top:50%; transform:translateY(-50%);
width:22px; height:22px; border-radius:50%; background:#151316; color:#f3ead6;
display:flex; align-items:center; justify-content:center;
font-family:"Montserrat", sans-serif; font-weight:700; font-size:11px;
}
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn:hover:not(:disabled){ background:#fff7e6; }
/* border-color:#000 !important nécessaire : le contrat partagé
(static/style.css, règle générique .quizBoxChoiceBtn.is-correct/
is-wrong) pose déjà un liseré vert/rouge "!important" — sans ce
repli, il resterait visible par-dessus le fond gold/rouge-foncé
propre à ce modèle (demande explicite : "il n'y a pas le liseré
vert" sur la maquette — bordure NOIRE partout, jamais colorée). */
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct{ background:#e7b923 !important; border-color:#000 !important; color:#151316; }
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong{ background:#8f0f24 !important; border-color:#000 !important; color:#fff; text-decoration:line-through; }
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct::after,
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong::after{
position:absolute; right:10px; top:50%; transform:translateY(-50%);
font-family:"Montserrat", sans-serif; font-weight:700; font-size:10px;
padding:2px 6px; border:1.5px solid currentColor; border-radius:3px;
}
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct::after{ content:"正解"; }
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong::after{ content:"不正解"; }
/* Bandeau de pied de page TOUJOURS visible (voir
quizMangaDialogueFooterBand, render_quiz_box_manga_dialogue) — comme
la maquette validée : une bande sombre distincte, jamais la même
couleur que le corps crème. */
.quizBoxWidget--mangaDialogue .quizMangaDialogueFooterBand{
/* 44px auparavant : trop bas pour le bouton (padding 8px×2 + texte +
bordure 2px×2 ≈ 40px) et son décalage bottom:8px — il débordait par
le HAUT, à moitié caché sous le panneau crème (demande explicite :
"ou dois etre le bouton"). 72px laisse une vraie marge de respir,
comme la maquette. */
position:absolute; left:0; right:0; bottom:0; height:72px;
background:#151316; pointer-events:none;
/* Coin haut-gauche dentelé (demande explicite : "les bords du bas
doivent être retravaillés") — écho du bandeau titre découpé en
haut, plutôt qu'un bas de carte parfaitement rectangulaire. Même
technique drop-shadow que le bandeau titre (un `border` classique
ne suit pas un clip-path) pour un liseré qui épouse la VRAIE
silhouette découpée. */
clip-path:polygon(4% 0, 100% 0, 100% 100%, 0 100%, 0 4%);
filter:drop-shadow(0 -2px 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow(2px 0 0 #000) drop-shadow(0 2px 0 #000);
}
/* Le bouton "Continuer →" du moteur partagé atterrit en DERNIER enfant
de .quizBoxChoices (jamais dans quizMangaDialogueFooterBand, que le
moteur ne remplit jamais) — posé PAR-DESSUS ce bandeau en
position:absolute (.quizBoxWidget--mangaDialogue est position:relative,
voir plus haut), petit et skewé aligné à droite comme la maquette
(sans ce repli, il s'étire à la largeur des choix — .quizBoxChoices
est un flex-column, stretch par défaut). */
.quizBoxWidget--mangaDialogue .quizBoxContinueBtn{
position:absolute; right:20px; bottom:20px; z-index:1; margin:0;
background:#d81e3e; border:2px solid #000; color:#fff2ec; border-radius:0;
padding:8px 20px; transform:skewX(-6deg); font-family:"Montserrat", sans-serif; font-weight:700;
box-shadow:3px 3px 0 #000;
}
.quizBoxWidget--mangaDialogue .quizBoxContinueBtn:hover:not(:disabled){ background:#8f0f24; }
/* ---------- Modèle "Manga" — premier "PAGE DE QUIZ" (voir
screens/rendering/quiz_box_templates.py::render_quiz_box_page_manga),
validé par le créateur via une maquette interactive avant intégration.
Thème complet et autonome : structure/police/formes/couleurs propres,
AUCUNE règle partagée avec les modèles "boîte de dialogue" ci-dessus
(à part .quizBoxChoiceBtn/.quizBoxContinueBtn, injectés par le moteur
JS partagé, voir static/js/play/dialogue-box-controller.js).
Polices : Montserrat/Poppins (déjà auto-hébergées, voir
static/vendor/fonts/fonts.css) plutôt que Bangers/Noto Sans JP de la
maquette d'origine — un paquet SCORM exporté tourne SANS accès
internet dans le LMS du client, une police chargée depuis Google
Fonts y resterait invisible (repli silencieux sur la police système). */
.quizBoxWidget--manga{
background:#151316; color:#f3ead6;
font-family:"Poppins", "Montserrat", system-ui, sans-serif;
display:flex; flex-direction:column; align-items:center; gap:18px; padding:20px;
overflow-y:auto;
background-image:
radial-gradient(circle at 14% 8%, rgba(216,30,62,.16), transparent 42%),
repeating-linear-gradient(115deg, rgba(255,255,255,.03) 0 2px, transparent 2px 26px);
}
/* Contenu centré en colonne confortable (demande explicite, comme la
maquette validée) plutôt qu'étiré sur toute la largeur de l'écran. */
.quizBoxWidget--manga .mangaTopbar,
.quizBoxWidget--manga .mangaProgress,
.quizBoxWidget--manga .mangaPanelQuestion,
.quizBoxWidget--manga .mangaChoices{ width:100%; max-width:860px; flex-shrink:0; }
/* Barre d'étapes (voir forgeQuizMangaOnShow, static/js/play/
quiz-box-templates.js) — un <i> par question de CE dialogue, remplie
EN JEU (le nombre total de questions n'est connu qu'à l'exécution). */
.quizBoxWidget--manga .mangaProgress{ display:flex; gap:6px; }
.quizBoxWidget--manga .mangaProgress i{ flex:1; height:6px; background:rgba(243,234,214,.18); border-radius:1px; font-style:normal; }
.quizBoxWidget--manga .mangaProgress i.is-done{ background:#d81e3e; }
.quizBoxWidget--manga .mangaProgress i.is-current{ background:#e7b923; }
.quizBoxWidget--manga .mangaTopbar{ display:flex; align-items:stretch; gap:12px; flex-wrap:wrap; }
.quizBoxWidget--manga .mangaPlate{
background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:4px 4px 0 #1d1a1f;
padding:8px 14px; display:flex; flex-direction:column; justify-content:center; gap:2px; min-width:118px;
}
.quizBoxWidget--manga .mangaPlateLabel{ font-size:10px; font-weight:600; letter-spacing:.14em; text-transform:uppercase; opacity:.65; }
.quizBoxWidget--manga .mangaPlateValue{
font-family:"Montserrat", sans-serif; font-weight:700; font-size:22px; line-height:1;
letter-spacing:.02em; font-variant-numeric:tabular-nums;
}
.quizBoxWidget--manga .mangaPlate--score .mangaPlateValue{ color:#8f0f24; }
.quizBoxWidget--manga .mangaPlate--timer:has(.quizBoxTimerDisplay.is-urgent){
background:#d81e3e; color:#f3ead6; animation:mangaTimerPulse .5s ease infinite;
}
.quizBoxWidget--manga .mangaPlate--timer:has(.quizBoxTimerDisplay.is-urgent) .mangaPlateValue{ color:#f3ead6; }
@keyframes mangaTimerPulse{ 0%,100%{ transform:scale(1); } 50%{ transform:scale(1.05); } }
.quizBoxWidget--manga .mangaTitlemark{
flex:1 1 200px; min-width:180px; background:#1d1a1f; border:3px solid #151316; outline:3px solid #f3ead6; outline-offset:-8px;
display:flex; align-items:center; justify-content:center; padding:10px;
background-image:radial-gradient(rgba(243,234,214,.14) 1.3px, transparent 1.5px); background-size:8px 8px;
}
.quizBoxWidget--manga .mangaTitlemark span{
font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; text-transform:uppercase;
font-size:clamp(16px,3vw,24px); letter-spacing:.02em; color:#f3ead6;
-webkit-text-stroke:1px #f3ead6; text-shadow:2px 2px 0 #d81e3e; transform:skewX(-8deg); display:inline-block;
}
.quizBoxWidget--manga .mangaPanelQuestion{
background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:6px 6px 0 #1d1a1f;
clip-path:polygon(0 0, 100% 0%, 100% 92%, 97% 100%, 3% 100%, 0 88%);
padding:22px 26px 30px; flex:0 0 auto;
}
.quizBoxWidget--manga .mangaKicker{
display:inline-block; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase;
background:#d81e3e; color:#f3ead6; padding:3px 10px; margin-bottom:12px; transform:rotate(-2deg);
}
.quizBoxWidget--manga .mangaQuestionText{
margin:0; font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; line-height:1.2;
font-size:clamp(19px,2.6vw,28px); letter-spacing:-.01em; text-transform:uppercase; text-wrap:balance;
}
.quizBoxWidget--manga .mangaChoices{
display:grid; grid-template-columns:1fr 1fr; gap:14px; flex:1 1 auto; align-content:start;
}
@media (max-width:620px){ .quizBoxWidget--manga .mangaChoices{ grid-template-columns:1fr; } }
.quizBoxWidget--manga .quizBoxChoiceBtn{
counter-increment:mangaChoice; position:relative; display:flex; align-items:center; gap:14px;
background:#1d1a1f; color:#f3ead6; border:3px solid #151316; outline:2px solid rgba(243,234,214,.5); outline-offset:-7px;
padding:12px 16px 12px 12px; font-family:inherit; font-size:15px; font-weight:500; text-align:left;
border-radius:0; box-shadow:none;
}
.quizBoxWidget--manga .mangaChoices{ counter-reset:mangaChoice; }
.quizBoxWidget--manga .quizBoxChoiceBtn::before{
content:counter(mangaChoice, upper-alpha); flex:0 0 auto; width:34px; height:34px; border-radius:50%;
background:#f3ead6; color:#151316; border:3px solid #151316; display:flex; align-items:center; justify-content:center;
font-family:"Montserrat", sans-serif; font-weight:700; font-size:15px;
}
.quizBoxWidget--manga .quizBoxChoiceBtn:hover:not(:disabled){ background:#26222a; transform:translate(-2px,-2px); }
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct{ background:#e7b923 !important; color:#151316; border-color:#151316 !important; }
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::before{ background:#151316; color:#e7b923; border-color:#151316; }
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong{ background:#8f0f24 !important; border-color:#151316 !important; }
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::after,
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::after{
position:absolute; top:50%; right:14px; transform:translateY(-50%) rotate(-8deg);
font-family:"Montserrat", sans-serif; font-weight:700; font-size:13px; letter-spacing:.06em;
padding:2px 8px; border:2px solid currentColor; border-radius:4px;
}
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::after{ content:"正解"; color:#151316; }
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::after{ content:"不正解"; color:#f3ead6; }
/* Une réponse fausse est barrée (demande explicite, comme la maquette) —
jamais le badge lettré (::before), qui reste un simple repère A/B/C/D. */
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong{ text-decoration:line-through; text-decoration-thickness:2px; }
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::before{ text-decoration:none; }
/* Posé DANS la grille des choix (voir forgeQuizBoxAnswer, moteur partagé)
mais sur sa propre ligne, aligné à droite en petit pavé (comme la
maquette) plutôt qu'étiré à la largeur d'une case de la grille — et
relabellisé en "Suite ▶" (texte "Continuer →" du moteur partagé
conservé pour les lecteurs d'écran, juste masqué visuellement). */
.quizBoxWidget--manga .quizBoxContinueBtn{
grid-column:1 / -1; justify-self:end; width:max-content;
font-family:"Montserrat", sans-serif; font-weight:700; letter-spacing:.03em; font-size:0;
background:#d81e3e; color:#f3ead6; border:3px solid #151316; box-shadow:3px 3px 0 #1d1a1f;
padding:9px 22px; transform:skewX(-6deg); border-radius:0;
}
.quizBoxWidget--manga .quizBoxContinueBtn::after{ content:"Suite ▶"; font-size:14px; }
.quizBoxWidget--manga .quizBoxContinueBtn:hover:not(:disabled){ background:#8f0f24; }
/* Écran de résultat de fin de quiz (voir forgeQuizMangaOnQuizComplete,
static/js/play/quiz-box-templates.js) — demande explicite : "il faut
aussi adapter le modele manga pour avoir sa page de résultat", même
mécanique que "classique" mais dans le thème manga propre à ce
modèle. Pas de conteneur "content" à part ici : c'est la RACINE
(.quizBoxWidget--manga) qui bascule en "is-showing-result" — le
bandeau topbar/score et la barre d'étapes (déjà remplie à 100%,
voir le JS) restent visibles, seuls le panneau question et les choix
disparaissent. */
.quizBoxWidget--manga.is-showing-result > .mangaPanelQuestion,
.quizBoxWidget--manga.is-showing-result > .mangaChoices{ display:none; }
.mangaResultZone{
display:none; flex-direction:column; align-items:center; justify-content:center;
width:100%; max-width:860px; text-align:center; flex:1 1 auto;
}
.quizBoxWidget--manga.is-showing-result .mangaResultZone{ display:flex; }
.mangaResultCard{
background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:6px 6px 0 #1d1a1f;
clip-path:polygon(0 0, 100% 0%, 100% 92%, 97% 100%, 3% 100%, 0 88%);
padding:32px 30px 40px; width:100%;
}
.mangaResultTitle{
font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; text-transform:uppercase;
font-size:clamp(26px,4vw,38px); text-shadow:3px 3px 0 #d81e3e; transform:skewX(-4deg);
display:inline-block; margin:0 0 10px;
}
.mangaResultScore{ font-family:"Montserrat", sans-serif; font-weight:700; font-size:19px; color:#8f0f24; margin:0 0 18px; }
.mangaResultBadge{
display:inline-block; font-family:"Montserrat", sans-serif; font-weight:700; font-size:22px;
letter-spacing:.05em; padding:6px 22px; border:3px solid #151316; margin-bottom:24px; transform:rotate(-2deg);
}
.mangaResultBadge.pass{ background:#e7b923; }
.mangaResultBadge.fail{ background:#8f0f24; color:#f3ead6; }
.mangaQuitBtn{
font-family:"Montserrat", sans-serif; font-weight:700; letter-spacing:.03em; cursor:pointer;
background:#151316; color:#f3ead6; border:3px solid #151316; box-shadow:3px 3px 0 #d81e3e;
padding:10px 26px; transform:skewX(-6deg); border-radius:0;
}
.mangaQuitBtn:hover{ background:#26222a; }
/* "Classique" (modèle "page de quiz" DE BASE) — maquette interactive
validée par le créateur (formation sécurité incendie comme exemple) :
couleurs VOLONTAIREMENT identiques à celles par défaut du modèle
"boîte de dialogue" de base (#2f6fed/#1a1d24/#11141b, voir
screens/rendering/dialogue_box_style.py::DEFAULT_DIALOGUE_BOX_STYLE)
plutôt qu'une palette propre — contrairement à "manga". */
/* Police : "Poppins" (déjà auto-hébergée pour "manga", voir
static/vendor/fonts/fonts.css) — jamais "IBM Plex Sans/Mono" de la
maquette (Google Fonts), qui casserait l'export SCORM hors-ligne, même
contrainte déjà rencontrée pour "manga". */
.quizBoxWidget--classique{
position:relative; display:flex; flex-direction:column; font-family:"Poppins", system-ui, sans-serif;
}
.classiqueHeaderBar{
display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap;
padding:14px 20px; color:#fff;
}
.classiqueHeaderTitleWrap{ display:flex; flex-direction:column; gap:1px; min-width:0; }
.classiqueHeaderEyebrow{ font-size:10px; font-weight:600; letter-spacing:.09em; text-transform:uppercase; color:rgba(255,255,255,.7); }
.classiqueHeaderTitle{ font-size:16px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.classiqueHeaderChips{ display:flex; gap:8px; flex-shrink:0; }
.classiqueChip{
display:flex; flex-direction:column; align-items:center; justify-content:center;
background:rgba(255,255,255,.14); border-radius:8px; padding:4px 12px; min-width:56px;
}
.classiqueChipLabel{ font-size:9px; font-weight:600; letter-spacing:.08em; text-transform:uppercase; color:rgba(255,255,255,.65); }
.classiqueChipValue{
font-family:"Roboto", monospace; font-size:13.5px; font-weight:600; color:#fff;
font-variant-numeric:tabular-nums;
}
/* Bug identifié (demande répétée : "le mot aucun a une background
color... un bord arrondi") : [data-quiz-role="timer"] porte AUSSI la
classe partagée .quizBoxTimerDisplay (voir plus haut dans ce fichier —
nécessaire pour le mécanisme commun ":empty"/".is-urgent") qui pose
SA PROPRE pastille (fond + border-radius:999px) — DEUX pastilles
s'empilaient donc : celle, correcte et voulue, du conteneur
".classiqueChip" (même fond que "Score"), et celle, parasite, de
".quizBoxTimerDisplay" collée UNIQUEMENT autour du mot "Aucun"/du
temps restant. On neutralise ICI cette seconde pastille (le
conteneur ".classiqueChip" reste seul responsable du fond, TOUJOURS
visible, identique que pour "Score", avec ou sans minuteur réglé). */
.classiqueChipValue.quizBoxTimerDisplay{ background:none; padding:0; border-radius:0; }
.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent){ background:rgba(224,82,77,.35); }
.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent) .classiqueChipValue,
.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent) .classiqueChipLabel{ color:#ffb3b0; }
.classiqueBody{ flex:1; display:flex; flex-direction:column; align-items:center; padding:24px 20px 96px; overflow-y:auto; }
.classiqueContent{ width:100%; max-width:640px; display:flex; flex-direction:column; flex:1; }
/* Barre d'étapes — voir forgeQuizClassiqueOnShow (static/js/play/
quiz-box-templates.js), même mécanique que "manga". */
.classiqueProgress{ display:flex; gap:6px; margin-bottom:24px; }
.classiqueProgress i{ flex:1; height:4px; border-radius:2px; background:rgba(255,255,255,.14); font-style:normal; }
.classiqueProgress i.is-done{ background:#2ecc71; }
.classiqueProgress i.is-current{ background:#fff; }
.classiqueKicker{
display:inline-block; align-self:flex-start; background:rgba(255,255,255,.14); border-radius:999px;
padding:4px 12px; font-size:11.5px; font-weight:600; color:rgba(255,255,255,.75); margin-bottom:16px;
}
.classiqueQuestionText{ margin:0 0 22px; font-weight:600; line-height:1.35; font-size:clamp(18px,2.6vw,22px); text-wrap:balance; }
.classiqueChoices{ display:flex; flex-direction:column; gap:10px; }
.classiqueChoices .quizBoxChoiceBtn{
display:flex; align-items:center; gap:14px; border-radius:8px; padding:13px 16px;
}
.classiqueChoices .quizBoxChoiceBtn.is-correct{ color:#08210f; }
.classiqueChoices .quizBoxChoiceBtn.is-wrong{ text-decoration:line-through; opacity:.85; }
/* Bandeau de pied de page TOUJOURS visible (voir classiqueFooterBand,
render_quiz_box_page_classique) — couleur configurable "🎨 Style"
(footer_bg, voir --classique-footer-bg posée sur la racine du widget)
plutôt qu'une couleur figée en dur, demande explicite : "il manque le
footer foncé avec le bouton" (comme la maquette validée). */
.classiqueFooterBand{
position:absolute; left:0; right:0; bottom:0; height:66px;
background:var(--classique-footer-bg, #11141b); border-top:1px solid rgba(255,255,255,.08);
pointer-events:none;
}
/* Le bouton "Continuer →" du moteur partagé atterrit en DERNIER enfant
de .classiqueChoices (jamais dans classiqueFooterBand, que le moteur
ne remplit jamais) — posé PAR-DESSUS ce bandeau en position:absolute
(.quizBoxWidget--classique est position:relative, voir plus haut).
Demande explicite : aligné à DROITE — dans la même colonne centrée de
640px que le corps (.classiqueContent) : `max(20px, calc(50vw - 320px))`
recalcule le bord DROIT de CETTE colonne (640px = 2×320px, 20px = le
padding latéral de .classiqueBody) plutôt que le bord droit de TOUT le
widget. 50vw est fiable ici : ce modèle n'existe qu'en plein écran, où
`.is-fullscreen` force déjà `width:100vw`. */
.classiqueChoices .quizBoxContinueBtn{
position:absolute; right:max(20px, calc(50vw - 320px)); bottom:14px; z-index:1; margin:0;
background:#2f6fed; border:1px solid #2f6fed; border-radius:7px; padding:10px 22px; font-weight:600;
}
.classiqueChoices .quizBoxContinueBtn:hover:not(:disabled){ background:#255bc4; }
/* Encart d'explication après réponse — RÉSERVÉ pour une fonctionnalité
FUTURE non encore implémentée (voir le commentaire dans
render_quiz_box_page_classique, screens/rendering/quiz_box_templates.py)
: reste toujours vide aujourd'hui, donc toujours caché — seul :not(:empty)
le fera apparaître le jour où un vrai contenu y sera écrit. */
.classiqueExplain:not(:empty){
margin-top:14px; padding:14px 16px; border-radius:8px; background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.14); font-size:13.5px; color:rgba(255,255,255,.75); line-height:1.5;
}
/* Écran de résultat de fin de quiz (voir forgeQuizClassiqueOnQuizComplete/
forgeQuizResultRestart, static/js/play/quiz-box-templates.js et
dialogue-box-controller.js) — 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'avait jusqu'ici AUCUN écran
de fin, juste la maquette artifact). ".classiqueContent.is-showing-
result" masque tout le reste (progression/question/choix/explication)
pour ne laisser QUE ce bloc visible, sans reconstruire le DOM du
widget (le contrat data-quiz-role="header/question/choices/score/
timer" reste intact pour un "Recommencer" qui relance normalement). */
.classiqueContent.is-showing-result > *{ display:none; }
.classiqueContent.is-showing-result .classiqueResultZone{ display:flex; }
.classiqueResultZone{
display:none; flex:1; flex-direction:column; align-items:center; justify-content:center;
text-align:center; padding:20px 0;
}
.classiqueResultRing{
width:112px; height:112px; margin:0 0 20px; border-radius:50%;
display:flex; align-items:center; justify-content:center; position:relative;
background:conic-gradient(#2f6fed calc(var(--pct, 0) * 1%), rgba(255,255,255,.14) 0);
}
.classiqueResultRing::before{
content:""; position:absolute; inset:8px; border-radius:50%; background:var(--classique-body-bg, #1a1d24);
}
.classiqueResultRing span{ position:relative; font-weight:700; font-size:22px; font-variant-numeric:tabular-nums; }
.classiqueResultTitle{ font-size:19px; font-weight:700; margin:0 0 6px; }
.classiqueResultSub{ font-size:13.5px; color:rgba(255,255,255,.62); margin:0 0 22px; }
.classiqueResultBadge{
display:inline-flex; align-items:center; gap:6px; padding:6px 16px; border-radius:999px;
font-size:12.5px; font-weight:700; margin-bottom:26px;
}
.classiqueResultBadge.pass{ background:rgba(46,204,113,.16); color:#2ecc71; }
.classiqueResultBadge.fail{ background:rgba(224,82,77,.16); color:#e0524d; }
.classiqueQuitBtn{
font:inherit; font-weight:600; font-size:13.5px; color:#fff; background:transparent;
border:1px solid rgba(255,255,255,.3); border-radius:9px; padding:10px 20px; cursor:pointer;
}
.classiqueQuitBtn:hover{ border-color:#2f6fed; background:rgba(255,255,255,.08); }
/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/
déclencheur, contrairement aux deux widgets ci-dessus), voir