Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -41,8 +41,8 @@
|
||||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||
// l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
const forgeDialogueBoxState = {};
|
||||
const forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||
// uniquement à calculer un taux de réussite global (voir
|
||||
@@ -56,17 +56,18 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------- Boîte de dialogue ----------
|
||||
|
||||
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
||||
return document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .dialogueBoxWidget`);
|
||||
}
|
||||
|
||||
function forgeDialogueBoxWidgetById(objectId) {
|
||||
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
|
||||
return document.querySelector(`.dialogueBoxWidget[data-object-id="${objectId}"]`);
|
||||
}
|
||||
|
||||
function forgeDialogueBoxFooterEl(widget) {
|
||||
@@ -78,7 +79,7 @@ function forgeDialogueBoxFooterEl(widget) {
|
||||
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
|
||||
// sa propre voix off) prend sa place, pour ne jamais superposer deux
|
||||
// voix off en même temps si le joueur avance vite dans la conversation.
|
||||
var forgeDialogueLineAudio = null;
|
||||
let forgeDialogueLineAudio = null;
|
||||
|
||||
function forgeStopDialogueLineAudio() {
|
||||
if (forgeDialogueLineAudio) {
|
||||
@@ -88,14 +89,16 @@ function forgeStopDialogueLineAudio() {
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
const header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
const body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
if (header) header.textContent = line.speaker;
|
||||
if (body) body.textContent = line.text;
|
||||
forgeStopDialogueLineAudio();
|
||||
if (line.audio_url) {
|
||||
forgeDialogueLineAudio = new Audio(line.audio_url);
|
||||
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
|
||||
// lecture bloquée par le navigateur (pas d'interaction récente) : la
|
||||
// bulle reste lisible sans voix off plutôt que de planter.
|
||||
forgeDialogueLineAudio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,24 +106,24 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||
// affichée (voir forgeShowCurrentConversationLine).
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
const footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = `<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(${objectId})">Suivant →</button>`;
|
||||
}
|
||||
|
||||
// ---------- Boîte à quiz ----------
|
||||
|
||||
function forgeQuizBoxWidgetOnCurrentScreen() {
|
||||
var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||
const 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 + '"]');
|
||||
return document.querySelector(`.quizBoxWidget[data-owner-screen-id="${window.currentScreenId}"]`);
|
||||
}
|
||||
|
||||
function forgeQuizBoxWidgetById(objectId) {
|
||||
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
|
||||
return document.querySelector(`.quizBoxWidget[data-object-id="${objectId}"]`);
|
||||
}
|
||||
|
||||
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
|
||||
@@ -137,7 +140,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
||||
// dessus de le retrouver malgré le déplacement.
|
||||
function forgeEscapeCameraForFullscreen(widget) {
|
||||
if (widget.parentNode === document.body) return;
|
||||
var screenEl = widget.closest('.playScreen');
|
||||
const 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);
|
||||
@@ -149,10 +152,10 @@ function forgeEscapeCameraForFullscreen(widget) {
|
||||
// n'est jamais laissé actif par erreur, quel que soit le chemin de
|
||||
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
|
||||
function forgeHideQuizBoxOnCurrentScreen() {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
widget.classList.remove('is-active', 'is-fullscreen');
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
const objectId = widget.getAttribute('data-object-id');
|
||||
forgeStopQuizTimer(objectId);
|
||||
}
|
||||
|
||||
@@ -164,10 +167,10 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
||||
// 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 = {};
|
||||
const forgeQuizTimerState = {};
|
||||
|
||||
function forgeStopQuizTimer(objectId) {
|
||||
var t = forgeQuizTimerState[objectId];
|
||||
const t = forgeQuizTimerState[objectId];
|
||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
||||
delete forgeQuizTimerState[objectId];
|
||||
}
|
||||
@@ -178,18 +181,19 @@ function forgeStopQuizTimer(objectId) {
|
||||
// d'une question à l'autre sans se réinitialiser.
|
||||
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
forgeStopQuizTimer(objectId);
|
||||
var state = { remaining: seconds };
|
||||
const state = { remaining: seconds };
|
||||
forgeQuizTimerState[objectId] = state;
|
||||
var timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||||
const 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;
|
||||
const remaining = Math.max(0, state.remaining);
|
||||
const m = Math.floor(remaining / 60); const
|
||||
s = remaining % 60;
|
||||
timerEl.textContent = `⏱ ${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
timerEl.classList.toggle('is-urgent', remaining <= 5);
|
||||
}
|
||||
render();
|
||||
state.intervalId = setInterval(function () {
|
||||
state.intervalId = setInterval(() => {
|
||||
state.remaining -= 1;
|
||||
render();
|
||||
if (state.remaining <= 0) {
|
||||
@@ -202,8 +206,8 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
// ---------- Score ----------
|
||||
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
}
|
||||
|
||||
@@ -213,8 +217,8 @@ function forgeUpdateAllScoreWidgets() {
|
||||
// .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;
|
||||
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
|
||||
}
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
@@ -237,7 +241,7 @@ function forgeSyncQuizScoreToScorm() {
|
||||
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||
// SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||||
@@ -264,21 +268,21 @@ function forgeSyncDialogueStartedToScorm() {
|
||||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
function forgeComputeFinalScormStatus() {
|
||||
var total = window.forgeQuizTotalAnswered || 0;
|
||||
const total = window.forgeQuizTotalAnswered || 0;
|
||||
if (total === 0) return 'termine';
|
||||
var percent = (window.forgeQuizCorrectAnswered / total) * 100;
|
||||
var threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const percent = (window.forgeQuizCorrectAnswered / total) * 100;
|
||||
const threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
return percent >= threshold ? 'reussi' : 'echoue';
|
||||
}
|
||||
|
||||
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
|
||||
const completableIds = (gameData.completable_dialogue_ids || []);
|
||||
const allDone = completableIds.length > 0 && completableIds.every((id) => !!window.forgeCompletedDialogueIds[id]);
|
||||
if (!allDone) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
var finalStatus = forgeComputeFinalScormStatus();
|
||||
const finalStatus = forgeComputeFinalScormStatus();
|
||||
gameData.scoring.status = finalStatus;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
|
||||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(finalStatus);
|
||||
}
|
||||
|
||||
function forgeAddQuizScore(amount) {
|
||||
@@ -295,10 +299,10 @@ function forgeAddQuizScore(amount) {
|
||||
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
|
||||
// n'est posée : impossible de bloquer la conversation indéfiniment).
|
||||
function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
var line = state.lines[state.index];
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
const line = state.lines[state.index];
|
||||
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
|
||||
@@ -336,8 +340,8 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// 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();
|
||||
var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId);
|
||||
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
@@ -347,8 +351,12 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[stateKey] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
dialogueId,
|
||||
objectName,
|
||||
lines,
|
||||
index: 0,
|
||||
markCompleted: !!markCompleted,
|
||||
thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(stateKey);
|
||||
}
|
||||
@@ -358,7 +366,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
|
||||
function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
window.forgeCompletedDialogueIds[dialogueId] = true;
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === 'function') forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
}
|
||||
|
||||
@@ -370,13 +378,13 @@ function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||||
// dialogue est fini", jamais avant.
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeStopDialogueLineAudio();
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
@@ -391,7 +399,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
|
||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||
function forgeDialogueBoxAdvance(objectId) {
|
||||
var state = forgeDialogueBoxState[objectId];
|
||||
const state = forgeDialogueBoxState[objectId];
|
||||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||||
forgeAdvanceConversation(objectId);
|
||||
}
|
||||
@@ -403,13 +411,19 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||||
// réponse qu'aucun widget ne peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var previousState = forgeQuizBoxState[quizObjectId];
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const 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,
|
||||
dialogueObjectId,
|
||||
correctIndex: line.correct_index,
|
||||
rewardAmount: line.reward_amount,
|
||||
resolved: false,
|
||||
dialogueId,
|
||||
objectName,
|
||||
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
|
||||
@@ -423,40 +437,38 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
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"]');
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
const questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
const choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (header) header.textContent = objectName || '';
|
||||
if (questionEl) questionEl.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
||||
}).join('');
|
||||
choicesEl.innerHTML = line.choices.map((choice, i) => `<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>';
|
||||
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';
|
||||
const 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;
|
||||
const timerMode = widget.getAttribute('data-timer-mode');
|
||||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
if (timerMode === 'question') {
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
} else if (timerMode !== 'quiz') {
|
||||
forgeStopQuizTimer(quizObjectId);
|
||||
}
|
||||
@@ -469,7 +481,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
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;
|
||||
const showFx = forgeQuizTemplateEffectsFor(widget).onShow;
|
||||
if (showFx) showFx(widget);
|
||||
}
|
||||
|
||||
@@ -477,7 +489,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// 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';
|
||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||
}
|
||||
|
||||
@@ -491,11 +503,11 @@ function forgeQuizTemplateEffectsFor(widget) {
|
||||
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
|
||||
// second clic pendant cet affichage est ignoré (`resolved`).
|
||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
state.resolved = true;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var correct = choiceIndex === state.correctIndex;
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const 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
|
||||
@@ -503,8 +515,8 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// 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++) {
|
||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].disabled = true;
|
||||
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
|
||||
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
|
||||
@@ -512,7 +524,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
}
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
var fx = forgeQuizTemplateEffectsFor(widget);
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
if (correct) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
state.correctCount += 1;
|
||||
@@ -521,13 +533,28 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
} 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);
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||
forgeXapiNotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
state.objectName,
|
||||
state.questionIndex,
|
||||
state.questionText,
|
||||
correct,
|
||||
correct ? state.rewardAmount : 0,
|
||||
);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
||||
forgeScorm2004NotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
state.objectName,
|
||||
state.questionIndex,
|
||||
state.questionText,
|
||||
correct,
|
||||
choiceIndex,
|
||||
state.correctIndex,
|
||||
);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
@@ -539,7 +566,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
||||
// choix juste au-dessus).
|
||||
if (widget) {
|
||||
var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -558,18 +585,18 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// les lignes.
|
||||
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
if (!widget) return null;
|
||||
var dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
const dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
if (!dialogueState) return null;
|
||||
var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
const isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
if (!isEnding) return null;
|
||||
var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
const completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
if (!completeFx) return null;
|
||||
var correctCount = state.correctCount;
|
||||
var answeredCount = state.answeredCount;
|
||||
const { correctCount } = state;
|
||||
const { answeredCount } = state;
|
||||
return function () {
|
||||
widget.classList.add('is-active');
|
||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
||||
completeFx(widget, { correctCount: correctCount, totalCount: answeredCount });
|
||||
completeFx(widget, { correctCount, totalCount: answeredCount });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -593,10 +620,10 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
|
||||
// courant en pratique.
|
||||
function forgeQuizResultQuit(quizObjectId) {
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
if (widget) {
|
||||
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
|
||||
@@ -608,10 +635,10 @@ function forgeQuizResultQuit(quizObjectId) {
|
||||
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
|
||||
// automatique (voir le commentaire ci-dessus).
|
||||
function forgeQuizBoxContinue(quizObjectId) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
@@ -629,31 +656,31 @@ function forgeQuizBoxContinue(quizObjectId) {
|
||||
// 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];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
forgeQuizBoxAnswer(quizObjectId, -1);
|
||||
if (!wholeQuizExpired) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const 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');
|
||||
const continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
|
||||
}
|
||||
setTimeout(function () {
|
||||
var s = forgeQuizBoxState[quizObjectId];
|
||||
setTimeout(() => {
|
||||
const 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);
|
||||
const 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];
|
||||
const dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
|
||||
if (dialogueState) {
|
||||
dialogueState.index = dialogueState.lines.length;
|
||||
forgeAdvanceConversation(s.dialogueObjectId);
|
||||
@@ -666,17 +693,34 @@ function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
|
||||
// contrôleurs de ce dossier.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowDialogueBox,
|
||||
forgeDialogueBoxAdvance,
|
||||
forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen,
|
||||
forgeDialogueBoxWidgetById,
|
||||
forgeRenderDialogueBoxLine,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore,
|
||||
forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeShowQuizBox,
|
||||
forgeQuizBoxAnswer,
|
||||
forgeQuizBoxContinue,
|
||||
forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen,
|
||||
forgeQuizBoxWidgetById,
|
||||
forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets,
|
||||
forgeUpdateQuizBoxScoreDisplays,
|
||||
forgeAddQuizScore,
|
||||
forgeAdvanceConversation,
|
||||
forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm,
|
||||
forgeSyncDialogueStartedToScorm,
|
||||
forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState,
|
||||
forgeStartQuizTimer,
|
||||
forgeStopQuizTimer,
|
||||
forgeQuizTimerState,
|
||||
forgeEscapeCameraForFullscreen,
|
||||
forgeQuizPrepareResultScreen, forgeQuizResultQuit,
|
||||
forgeQuizPrepareResultScreen,
|
||||
forgeQuizResultQuit,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user