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>
727 lines
36 KiB
JavaScript
727 lines
36 KiB
JavaScript
// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ----------
|
||
// Fait le lien entre l'éditeur de déclencheurs (action "dialogue", voir
|
||
// static/js/play/collision-rules-controller.js) et les widgets d'interface
|
||
// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||
// "dialogue", parcourt les répliques DE CETTE ACTION PRÉCISE
|
||
// (action.dialogue.lines, transmises telles quelles par
|
||
// collision-rules-controller.js — chaque occurrence de "Déclencher ce
|
||
// dialogue" porte son propre dialogue, voir screens/rendering/
|
||
// collision_rules.py) ligne par ligne — une ligne "dialogue" s'affiche
|
||
// dans la boîte de dialogue (bouton "Suivant" pour avancer) ; une ligne
|
||
// "question" ("❓ Question", voir static/js/triggers/trigger-editor.js)
|
||
// s'affiche dans la boîte à QUIZ (si posée sur la scène — sinon ignorée
|
||
// silencieusement, la conversation avance sans elle) : cliquer la BONNE
|
||
// réponse crédite le score du montant prévu et avance ; une mauvaise
|
||
// réponse ne fait RIEN, la question reste affichée (le joueur peut
|
||
// réessayer).
|
||
//
|
||
// Plus aucune "quête" ni écran d'offre automatique (Accepter/Refuser) :
|
||
// c'est le CRÉATEUR qui pilote l'avancement, via ses propres variables
|
||
// globales et conditions (voir screens/rendering/collision_rules.py::
|
||
// "condition") — le dialogue choisi s'affiche directement. Une action
|
||
// "dialogue" peut cocher `mark_completed` : une fois ce dialogue épuisé
|
||
// (ou immédiatement s'il est vide), son id est ajouté à
|
||
// forgeCompletedDialogueIds — RUNTIME uniquement (jamais persisté en
|
||
// base), qui sert à savoir quand calculer la synthèse SCORM finale (voir
|
||
// forgeSyncAllDialoguesCompletionToScorm : "terminé" une fois TOUS les
|
||
// dialogues `mark_completed` du jeu joués, voir
|
||
// gameData.completable_dialogue_ids). Le seuil de réussite/échec SCORM
|
||
// lui-même reste un réglage du créateur totalement indépendant
|
||
// (db/games/get_success_threshold.py), inchangé ici.
|
||
//
|
||
// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié
|
||
// à ce système de dialogue/quiz — distinct du score "classique" du moteur
|
||
// document (db/scoring/, action de flow "Modifier un score"), qui
|
||
// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors
|
||
// de propos pour ce sous-système entièrement client (voir
|
||
// collision-rules-controller.js : "Pur JS client, sans le moindre appel
|
||
// serveur").
|
||
//
|
||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||
// l'écran — même esprit que le reste du moteur.
|
||
|
||
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
|
||
// forgeComputeFinalScormStatus), distinct de window.forgeQuizScore
|
||
// qui ne compte que les points gagnés.
|
||
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
|
||
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
|
||
// Id de dialogue -> true une fois joué ET marqué `mark_completed` — voir
|
||
// forgeSyncAllDialoguesCompletionToScorm.
|
||
window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||
|
||
function forgeEscapeHtml(str) {
|
||
return String(str == null ? '' : str)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ---------- Boîte de dialogue ----------
|
||
|
||
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||
return document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .dialogueBoxWidget`);
|
||
}
|
||
|
||
function forgeDialogueBoxWidgetById(objectId) {
|
||
return document.querySelector(`.dialogueBoxWidget[data-object-id="${objectId}"]`);
|
||
}
|
||
|
||
function forgeDialogueBoxFooterEl(widget) {
|
||
return widget.querySelector('[data-dialogue-role="footer"]');
|
||
}
|
||
|
||
// Voix off d'une réplique (demande explicite : "ajouter une réplique
|
||
// audio", voir db/dialogue_lines.py::"dialogue".audio_url) — jouée dès
|
||
// 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.
|
||
let forgeDialogueLineAudio = null;
|
||
|
||
function forgeStopDialogueLineAudio() {
|
||
if (forgeDialogueLineAudio) {
|
||
forgeDialogueLineAudio.pause();
|
||
forgeDialogueLineAudio = null;
|
||
}
|
||
}
|
||
|
||
function forgeRenderDialogueBoxLine(widget, line) {
|
||
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);
|
||
// 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(() => {});
|
||
}
|
||
}
|
||
|
||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||
// affichée (voir forgeShowCurrentConversationLine).
|
||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||
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() {
|
||
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}"]`);
|
||
}
|
||
|
||
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;
|
||
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);
|
||
}
|
||
|
||
// 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() {
|
||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||
if (!widget) return;
|
||
widget.classList.remove('is-active', 'is-fullscreen');
|
||
const 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).
|
||
const forgeQuizTimerState = {};
|
||
|
||
function forgeStopQuizTimer(objectId) {
|
||
const 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);
|
||
const state = { remaining: seconds };
|
||
forgeQuizTimerState[objectId] = state;
|
||
const timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||
function render() {
|
||
if (!timerEl) return;
|
||
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(() => {
|
||
state.remaining -= 1;
|
||
render();
|
||
if (state.remaining <= 0) {
|
||
forgeStopQuizTimer(objectId);
|
||
onExpire();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// ---------- Score ----------
|
||
|
||
function forgeUpdateAllScoreWidgets() {
|
||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||
for (let 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() {
|
||
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/
|
||
// apply-actions.js, même forme {score, status}) — bug signalé : "les
|
||
// indicateurs qu'on utilise pour les dialogues devraient aussi servir aux
|
||
// indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que
|
||
// static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser
|
||
// cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y
|
||
// toucher, un jeu construit uniquement avec des dialogues/quiz (jamais
|
||
// l'action de flow "Modifier un score") laissait Score/Completion
|
||
// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué
|
||
// jusqu'au bout. Vocabulaire de statut identique à db/scoring/
|
||
// (non_commence/en_cours/termine/...), voir FORGE_TO_SCORM_STATUS.
|
||
function forgeSyncQuizScoreToScorm() {
|
||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||
gameData.scoring.score = window.forgeQuizScore;
|
||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
|
||
// dialogue/quiz contourne static/js/play/offline/apply-actions.js
|
||
// (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);
|
||
}
|
||
|
||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||
// encore gagné — sans cet appel, Score restait "0" mais Completion
|
||
// restait "unknown" tant qu'aucune question n'avait encore été résolue.
|
||
// Appelé depuis forgeShowDialogueBox.
|
||
function forgeSyncDialogueStartedToScorm() {
|
||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
|
||
}
|
||
|
||
// Le jeu entier est considéré terminé quand TOUS les dialogues du jeu
|
||
// marqués `mark_completed` (voir gameData.completable_dialogue_ids,
|
||
// screens/rendering/trigger_graph.py::list_completable_dialogue_ids) ont
|
||
// été joués (voir forgeAdvanceConversation) — règle simple et
|
||
// prévisible plutôt que de deviner lequel des dialogues créés "compte"
|
||
// pour la fin du jeu. Réussi/Échoué (voir db/games/get_success_threshold.py,
|
||
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
|
||
// build_scorm_package.py) : demande utilisateur — "il manque
|
||
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
|
||
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
|
||
// SUR TOUTE LA PARTIE, pas dialogue par dialogue — cohérent avec "tous
|
||
// les dialogues terminés" qui est déjà la seule condition de fin de
|
||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||
function forgeComputeFinalScormStatus() {
|
||
const total = window.forgeQuizTotalAnswered || 0;
|
||
if (total === 0) return 'termine';
|
||
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() {
|
||
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' };
|
||
const finalStatus = forgeComputeFinalScormStatus();
|
||
gameData.scoring.status = finalStatus;
|
||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(finalStatus);
|
||
}
|
||
|
||
function forgeAddQuizScore(amount) {
|
||
window.forgeQuizScore += (amount || 0);
|
||
forgeUpdateAllScoreWidgets();
|
||
forgeSyncQuizScoreToScorm();
|
||
}
|
||
|
||
// ---------- Enchaînement dialogue/question ----------
|
||
|
||
// Affiche la ligne COURANTE de l'état de conversation `dialogueObjectId`
|
||
// (voir forgeDialogueBoxState) — une ligne "dialogue" dans la boîte de
|
||
// dialogue, une ligne "question" dans la boîte à quiz (voir
|
||
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
|
||
// n'est posée : impossible de bloquer la conversation indéfiniment).
|
||
function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||
if (!state) return;
|
||
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);
|
||
return;
|
||
}
|
||
forgeHideQuizBoxOnCurrentScreen();
|
||
if (!widget) return;
|
||
forgeResetDialogueBoxFooter(widget, dialogueObjectId);
|
||
forgeRenderDialogueBoxLine(widget, line);
|
||
widget.classList.add('is-active');
|
||
}
|
||
|
||
// Appelée par l'action "dialogue" (collision-rules-controller.js) —
|
||
// trouve le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la
|
||
// fois, comme le reste du moteur 2D). `lines` : les répliques DE CETTE
|
||
// ACTION PRÉCISE (action.dialogue.lines, voir le commentaire d'en-tête).
|
||
// `objectName` : le nom de l'objet déclencheur (voir "ℹ️ Informations"),
|
||
// affiché en en-tête du quiz et transmis à xAPI — plus de "quête" pour
|
||
// le porter. `markCompleted` : voir forgeAdvanceConversation ci-dessous.
|
||
// `thenAction` (chaînage, voir screens/rendering/collision_rules.py::
|
||
// "then") : l'action suivante, exécutée seulement une fois ce dialogue
|
||
// épuisé — jamais avant, contrairement à une action "variable" qui,
|
||
// elle, chaîne immédiatement (voir collision-rules-controller.js).
|
||
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).
|
||
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
|
||
// immédiatement (rien à exhiber avant), sinon silencieux.
|
||
if (markCompleted) forgeCompleteDialogue(dialogueId, objectName);
|
||
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
|
||
return;
|
||
}
|
||
forgeDialogueBoxState[stateKey] = {
|
||
dialogueId,
|
||
objectName,
|
||
lines,
|
||
index: 0,
|
||
markCompleted: !!markCompleted,
|
||
thenAction,
|
||
};
|
||
forgeShowCurrentConversationLine(stateKey);
|
||
}
|
||
|
||
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
|
||
// le créateur pilote lui-même quand, via la case "Marquer ce dialogue
|
||
// 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);
|
||
forgeSyncAllDialoguesCompletionToScorm();
|
||
}
|
||
|
||
// Avance d'une ligne (fin de dialogue -> question, fin de question
|
||
// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par
|
||
// une bonne réponse au quiz (voir forgeQuizBoxAnswer). À l'épuisement des
|
||
// lignes : markCompleted PUIS thenAction (voir screens/rendering/
|
||
// collision_rules.py::"then", demande explicite : "pouvoir modifier un
|
||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||
// dialogue est fini", jamais avant.
|
||
function forgeAdvanceConversation(dialogueObjectId) {
|
||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||
if (!state) return;
|
||
state.index += 1;
|
||
if (state.index >= state.lines.length) {
|
||
forgeStopDialogueLineAudio();
|
||
forgeHideQuizBoxOnCurrentScreen();
|
||
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');
|
||
delete forgeDialogueBoxState[dialogueObjectId];
|
||
return;
|
||
}
|
||
forgeShowCurrentConversationLine(dialogueObjectId);
|
||
}
|
||
|
||
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
|
||
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)") — sans
|
||
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
|
||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||
function forgeDialogueBoxAdvance(objectId) {
|
||
const state = forgeDialogueBoxState[objectId];
|
||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||
forgeAdvanceConversation(objectId);
|
||
}
|
||
|
||
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
|
||
// l'écran — header = le nom de l'objet déclencheur (voir "ℹ️
|
||
// Informations"), corps = la question + ses choix — si l'auteur n'a posé
|
||
// aucune boîte à quiz, la question est ignorée (avance directement)
|
||
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||
// réponse qu'aucun widget ne peut recueillir.
|
||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||
const quizObjectId = widget.getAttribute('data-object-id');
|
||
const previousState = forgeQuizBoxState[quizObjectId];
|
||
forgeQuizBoxState[quizObjectId] = {
|
||
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
|
||
// 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,
|
||
};
|
||
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((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>`;
|
||
}
|
||
// "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.
|
||
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).
|
||
const timerMode = widget.getAttribute('data-timer-mode');
|
||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||
if (timerMode === 'question') {
|
||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
|
||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { 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
|
||
// ce retrait/reflow/réajout, une classe déjà posée ne redéclenche pas
|
||
// son animation CSS.
|
||
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.
|
||
const 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) {
|
||
const 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
|
||
// forgeShowQuizBox ci-dessus, jamais rendus côté serveur — voir
|
||
// render_scene_object.py pour le seul aperçu statique de l'éditeur).
|
||
// TOUJOURS crédite le score si la réponse est bonne PUIS avance à la
|
||
// ligne suivante, QUE la réponse soit bonne ou mauvaise (demande
|
||
// explicite : une mauvaise réponse ne doit jamais bloquer la
|
||
// progression) — surligne la bonne réponse en vert (et le choix cliqué
|
||
// 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) {
|
||
const state = forgeQuizBoxState[quizObjectId];
|
||
if (!state || state.resolved) return;
|
||
state.resolved = true;
|
||
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
|
||
// "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) {
|
||
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');
|
||
}
|
||
}
|
||
window.forgeQuizTotalAnswered += 1;
|
||
state.answeredCount += 1;
|
||
const 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,
|
||
);
|
||
}
|
||
// 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,
|
||
);
|
||
}
|
||
// 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
|
||
// 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 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) {
|
||
const 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;
|
||
const dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||
if (!dialogueState) return null;
|
||
const isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||
if (!isEnding) return null;
|
||
const completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||
if (!completeFx) return null;
|
||
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, 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) {
|
||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||
if (widget) {
|
||
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
|
||
const 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 (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) {
|
||
const state = forgeQuizBoxState[quizObjectId];
|
||
if (!state) return;
|
||
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);
|
||
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) {
|
||
const state = forgeQuizBoxState[quizObjectId];
|
||
if (!state || state.resolved) return;
|
||
forgeQuizBoxAnswer(quizObjectId, -1);
|
||
if (!wholeQuizExpired) return;
|
||
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) {
|
||
const continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
|
||
}
|
||
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.
|
||
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.
|
||
const 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
|
||
// contrôleurs de ce dossier.
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = {
|
||
forgeShowDialogueBox,
|
||
forgeDialogueBoxAdvance,
|
||
forgeCompleteDialogue,
|
||
forgeDialogueBoxWidgetOnCurrentScreen,
|
||
forgeDialogueBoxWidgetById,
|
||
forgeRenderDialogueBoxLine,
|
||
forgeResetDialogueBoxFooter,
|
||
forgeShowQuizBox,
|
||
forgeQuizBoxAnswer,
|
||
forgeQuizBoxContinue,
|
||
forgeQuizBoxTimeout,
|
||
forgeQuizBoxWidgetOnCurrentScreen,
|
||
forgeQuizBoxWidgetById,
|
||
forgeHideQuizBoxOnCurrentScreen,
|
||
forgeUpdateAllScoreWidgets,
|
||
forgeUpdateQuizBoxScoreDisplays,
|
||
forgeAddQuizScore,
|
||
forgeAdvanceConversation,
|
||
forgeShowCurrentConversationLine,
|
||
forgeSyncQuizScoreToScorm,
|
||
forgeSyncDialogueStartedToScorm,
|
||
forgeSyncAllDialoguesCompletionToScorm,
|
||
forgeComputeFinalScormStatus,
|
||
forgeStartQuizTimer,
|
||
forgeStopQuizTimer,
|
||
forgeQuizTimerState,
|
||
forgeEscapeCameraForFullscreen,
|
||
forgeQuizPrepareResultScreen,
|
||
forgeQuizResultQuit,
|
||
};
|
||
}
|