Enrichit le reporting SCORM/xAPI et met en conformité RGAA le player
Build and deploy / test-python (push) Successful in 5m56s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

SCORM/xAPI :
- Ajoute l'export SCORM 2004 (3rd/4th edition) au choix, en plus du 1.2
  par défaut : sépare completion_status/success_status (un échec reste
  "completed" au lieu de retomber à tort en "incomplete" comme force la
  1.2), remonte aussi cmi.interactions.n.* par question répondue.
- Fournit cmi.core.score.min/max (1.2 et 2004), calculé depuis les
  récompenses de quiz du jeu, pour que le LMS affiche un vrai pourcentage
  au lieu du score brut à tort étiqueté "%".
- Libellés de verbes xAPI en français en plus de l'anglais.

Accessibilité (RGAA/WCAG 2.1 AA) sur le player :
- Navigation clavier des objets de scène "au clic"/"au survol"
  (tabindex, role, Entrée/Espace, focus/blur).
- alt sur les images (nom auteur ou décoratif), aria-hidden sur les
  icônes seules, role="dialog"/aria-live sur les boîtes de dialogue/quiz.
- Landmark <main> + titre de page, respect de prefers-reduced-motion.
- Le quiz n'avance plus automatiquement après un délai fixe : bouton
  "Continuer →" explicite (RGAA 2.2.1).
- Avertissement de contraste dans l'éditeur de style de dialogue.
- Déclaration d'accessibilité téléchargeable depuis la modale d'export.
This commit is contained in:
william
2026-09-06 00:17:51 +02:00
parent f7a50e5afe
commit c504ace167
36 changed files with 1581 additions and 79 deletions
+70 -20
View File
@@ -40,6 +40,12 @@
var forgeDialogueBoxState = {};
var 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
// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore
// qui ne compte que les points gagnés.
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
function forgeQuestById(questId) {
return (gameData.quests || []).find(function (q) { return q.id === questId; });
@@ -135,12 +141,30 @@ function forgeSyncQuestStartedToScorm() {
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues
// sont "terminee" — règle simple et prévisible plutôt que de deviner
// laquelle des quêtes créées "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 quête par quête — cohérent avec "toutes les
// quêtes terminées" 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() {
var 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;
return percent >= threshold ? 'reussi' : 'echoue';
}
function forgeSyncAllQuestsCompletionToScorm() {
var quests = gameData.quests || [];
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
gameData.scoring.status = 'termine';
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged('termine');
var finalStatus = forgeComputeFinalScormStatus();
gameData.scoring.status = finalStatus;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
}
function forgeAddQuizScore(amount) {
@@ -163,7 +187,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (line.type === 'question') {
if (widget) widget.classList.remove('is-active');
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line);
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line, state.index);
return;
}
forgeHideQuizBoxOnCurrentScreen();
@@ -215,6 +239,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
// questions la quête passe au statut terminé".
if (quest && quest.status === 'en_cours') {
quest.status = 'terminee';
if (typeof forgeXapiNotifyQuestCompleted === "function") forgeXapiNotifyQuestCompleted(quest);
forgeSyncAllQuestsCompletionToScorm();
}
if (widget) widget.classList.remove('is-active');
@@ -234,26 +259,19 @@ function forgeDialogueBoxAdvance(objectId) {
forgeAdvanceConversation(objectId);
}
// Combien de temps la bonne/mauvaise réponse reste affichée (surlignée,
// voir forgeQuizBoxAnswer) avant d'avancer automatiquement à la ligne
// suivante — assez long pour LIRE la bonne réponse en cas d'erreur, plus
// court si la réponse était déjà correcte (voir static/style.css,
// @keyframes quizPopIn/quizWrongShake pour l'animation elle-même).
var FORGE_QUIZ_REVEAL_DELAY_CORRECT = 700;
var FORGE_QUIZ_REVEAL_DELAY_WRONG = 1400;
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
// l'écran — header = "Quête : <titre>", corps = la question + ses choix
// (voir demande utilisateur) — 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, quest, line) {
function forgeShowQuizBox(dialogueObjectId, quest, line, questionIndex) {
var widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
var quizObjectId = widget.getAttribute('data-object-id');
forgeQuizBoxState[quizObjectId] = {
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
quest: quest, questionIndex: questionIndex, questionText: line.text,
};
var header = widget.querySelector('[data-quiz-role="header"]');
var questionEl = widget.querySelector('[data-quiz-role="question"]');
@@ -297,13 +315,43 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
}
}
if (correct) forgeAddQuizScore(state.rewardAmount);
var dialogueObjectId = state.dialogueObjectId;
setTimeout(function () {
if (widget) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(dialogueObjectId);
}, correct ? FORGE_QUIZ_REVEAL_DELAY_CORRECT : FORGE_QUIZ_REVEAL_DELAY_WRONG);
window.forgeQuizTotalAnswered += 1;
if (correct) {
window.forgeQuizCorrectAnswered += 1;
forgeAddQuizScore(state.rewardAmount);
}
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) {
forgeXapiNotifyQuestionAnswered(state.quest, 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" && state.quest) {
forgeScorm2004NotifyQuestionAnswered(state.quest, 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 est ajouté APRÈS les choix (jamais en remplaçant
// choicesEl.innerHTML, qui effacerait le surlignage vert/rouge déjà posé).
if (widget) {
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
if (choicesEl) {
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')">Continuer →</button>';
}
}
}
// Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer
// ci-dessus — masque la boîte à quiz et avance la conversation, 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');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(state.dialogueObjectId);
}
// ---------- Offre de quête (Accepter/Refuser) ----------
@@ -333,6 +381,7 @@ function forgeDialogueBoxAcceptQuest(questId, objectId) {
var quest = forgeQuestById(questId);
if (quest) quest.status = 'en_cours';
forgeSyncQuestStartedToScorm();
if (quest && typeof forgeXapiNotifyQuestAccepted === "function") forgeXapiNotifyQuestAccepted(quest);
var widget = forgeDialogueBoxWidgetById(objectId);
if (widget) widget.classList.remove('is-active');
delete forgeDialogueBoxState[objectId];
@@ -354,8 +403,9 @@ if (typeof module !== 'undefined' && module.exports) {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
forgeComputeFinalScormStatus,
};
}