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.
163 lines
8.9 KiB
JavaScript
163 lines
8.9 KiB
JavaScript
// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
|
|
// navigateur") — étape 4 : port des widgets à "special_render" les plus
|
|
// simples (screens/rendering/render_checkbox_or_radio.py, render_select.py,
|
|
// render_fieldset.py, render_table.py, render_icone.py, render_onglets.py).
|
|
// Jauge/Superposition/Personnage/Répéteur (logique/données plus lourde)
|
|
// vivent dans des fichiers dédiés (render-jauge.js, render-repeater.js).
|
|
// Dépend de render-element.js (forgeVisibleAttrs/forgeStyleString/
|
|
// forgeAttrString/forgeApplyCtx/forgeHtmlEscape/forgeAutoId/
|
|
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
|
|
|
|
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
var inputType = el.widget === "case_a_cocher" ? "checkbox" : "radio";
|
|
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
|
attrs.type = inputType;
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; align-items:center; gap:8px;";
|
|
var content = forgeApplyCtx(el.content || "", ctx);
|
|
return '<label ' + forgeAttrString({ class: inputType }, style) + '><input ' + forgeAttrString(attrs, "") + '> ' + forgeHtmlEscape(content) + '</label>';
|
|
}
|
|
|
|
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
var outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
|
var bulmaClasses = outerAttrs.class || "";
|
|
var selectAttrs = {}, wrapperAttrs = {};
|
|
Object.keys(outerAttrs).forEach(function (k) {
|
|
if (k === "id" || k === "data-element-id" || k === "data-anim-target") wrapperAttrs[k] = outerAttrs[k];
|
|
else if (k !== "class") selectAttrs[k] = outerAttrs[k];
|
|
});
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex;";
|
|
var content = forgeApplyCtx(el.content || "", ctx);
|
|
var options = content.split("\n").map(function (o) { return o.trim(); }).filter(Boolean);
|
|
var optsHtml = options.map(function (o) {
|
|
var esc = forgeHtmlEscape(o);
|
|
return '<option value="' + esc + '">' + esc + '</option>';
|
|
}).join("");
|
|
var selectHtml = '<select ' + forgeAttrString(selectAttrs, "") + '>' + optsHtml + '</select>';
|
|
var selectWrapClass = forgeHtmlEscape(("select is-fullwidth " + bulmaClasses).trim());
|
|
return '<div ' + forgeAttrString(wrapperAttrs, style) + '><div class="' + selectWrapClass + '">' + selectHtml + '</div></div>';
|
|
}
|
|
|
|
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
|
var content = forgeApplyCtx(el.content || "", ctx) || "Groupe de champs";
|
|
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
|
return '<fieldset ' + forgeAttrString(attrs, style) + '><legend>' + forgeHtmlEscape(content) + '</legend>' + childHtml + '</fieldset>';
|
|
}
|
|
|
|
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
|
var headerRow = ["1", "true", "on"].indexOf((el.attributes || {})._header_row) !== -1;
|
|
var content = forgeApplyCtx(el.content || "", ctx);
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
|
var rows = content.split("\n").filter(function (r) { return r.trim(); });
|
|
var htmlRows = rows.map(function (r, i) {
|
|
var cells = r.split(";");
|
|
var cellTag = (headerRow && i === 0) ? "th" : "td";
|
|
var cellsHtml = cells.map(function (c) {
|
|
return "<" + cellTag + ">" + forgeHtmlEscape(c.trim()) + "</" + cellTag + ">";
|
|
}).join("");
|
|
return "<tr>" + cellsHtml + "</tr>";
|
|
}).join("");
|
|
return '<table ' + forgeAttrString(attrs, style) + '>' + htmlRows + '</table>';
|
|
}
|
|
|
|
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
// Contrairement au serveur, ne revalide pas _icon_slug contre le
|
|
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
|
|
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
|
|
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
|
|
var rawAttrs = el.attributes || {};
|
|
var iconSlug = rawAttrs._icon_slug || "star";
|
|
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
|
var existingClass = attrs.class || "";
|
|
delete attrs.class;
|
|
attrs.class = (existingClass + " icon-svg").trim();
|
|
// data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
|
|
// publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
|
|
// chemin de fichier, même relatif : Firefox traite chaque ressource
|
|
// file:// comme une origine opaque distincte, et mask-image y déclenche
|
|
// un fetch en mode CORS quel que soit le chemin ("Blocage d'une requête
|
|
// multiorigine... la requête CORS n'utilise pas HTTP", signalé par un
|
|
// utilisateur MALGRÉ un chemin relatif par ailleurs correct). Repli sur
|
|
// un chemin relatif si jamais l'icône n'a pas été pré-encodée (ne
|
|
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
|
// "icone", posés directement ou nichés dans un élément de jeu
|
|
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
|
|
var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg");
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
|
|
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
|
|
// description dédié dans l'éditeur) est traitée comme purement
|
|
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
|
|
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
|
|
if (!("aria-hidden" in attrs)) attrs["aria-hidden"] = "true";
|
|
return "<i " + forgeAttrString(attrs, style) + "></i>";
|
|
}
|
|
|
|
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
var tabs = (childrenMap && childrenMap[el.id]) || [];
|
|
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
|
var tabsExtraClasses = attrs.class || "";
|
|
delete attrs.class;
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; flex-direction:column;";
|
|
var groupId = forgeAutoId(el);
|
|
|
|
if (!tabs.length) {
|
|
return '<div ' + forgeAttrString(attrs, style) + '><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>';
|
|
}
|
|
|
|
var flexDir = forgeParentFlexDirection(el);
|
|
var barItems = [], panels = [];
|
|
tabs.forEach(function (tab, i) {
|
|
var label = tab.name || ("Onglet " + (i + 1));
|
|
var active = i === 0;
|
|
var tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
|
var targetId = "tabpanel-" + tab.id;
|
|
barItems.push(
|
|
'<li class="' + (active ? "is-active" : "") + '" data-tab-btn="' + groupId + '" data-tab-target="' + targetId +
|
|
'" onclick="forgeShowTab(this)"><a>' + forgeHtmlEscape(String(label)) + '</a></li>'
|
|
);
|
|
var hiddenStyle = active ? "" : ' style="display:none;"';
|
|
panels.push('<div class="forgeTabPanel" id="' + targetId + '" data-tab-group="' + groupId + '"' + hiddenStyle + '>' + tabHtml + '</div>');
|
|
});
|
|
|
|
var tabsClass = forgeHtmlEscape(("tabs " + tabsExtraClasses).trim());
|
|
return (
|
|
'<div ' + forgeAttrString(attrs, style) + '>' +
|
|
'<div class="' + tabsClass + '"><ul>' + barItems.join("") + '</ul></div>' +
|
|
'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">' + panels.join("") + '</div>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
// Port de render_personnage.py — lit directement gameData.personnage_animations
|
|
// (déjà résolu une fois par full_game_payload.py, à l'export) au lieu de
|
|
// re-résoudre la bibliothèque de sprites Forge/l'URL personnalisée : ce
|
|
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
|
|
// rendu côté client.
|
|
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
|
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
|
attrs["data-personnage"] = "1";
|
|
var animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
|
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
|
var idleFrame = frames && frames.length ? frames[0] : "";
|
|
if (idleFrame) attrs.src = idleFrame;
|
|
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
|
// décoratif sur le widget "image" générique.
|
|
if (!("alt" in attrs)) attrs.alt = "";
|
|
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
|
return "<img " + forgeAttrString(attrs, style) + ">";
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = {
|
|
forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable,
|
|
forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage,
|
|
};
|
|
var renderMod = require('./render-element.js');
|
|
["forgeVisibleAttrs", "forgeStyleString", "forgeAttrString", "forgeApplyCtx", "forgeHtmlEscape",
|
|
"forgeAutoId", "forgeParentFlexDirection", "forgeRenderChildren", "forgeRenderElementHtml"].forEach(function (name) {
|
|
global[name] = global[name] || renderMod[name];
|
|
});
|
|
}
|