Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
66d8eaeae8
commit
ccf836f2c5
@@ -11,13 +11,32 @@ global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
const {
|
||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
applyElementProperty, applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
} = require('../actions.js');
|
||||
|
||||
function fakeImg() {
|
||||
return { src: '' };
|
||||
}
|
||||
|
||||
// Mock DOM minimal pour applyElementProperty() — classList/style/dataset
|
||||
// réels (Set + objet), pas juste des espions, pour pouvoir vérifier l'état
|
||||
// après coup (ex. "la classe a bien été retirée", pas seulement "la
|
||||
// méthode a été appelée").
|
||||
function fakeElement() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
style: { display: '', visibility: '' },
|
||||
dataset: {},
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
toggle: (c) => { if (classes.has(c)) classes.delete(c); else classes.add(c); },
|
||||
has: (c) => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => {
|
||||
const el = fakeImg();
|
||||
const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true };
|
||||
@@ -89,6 +108,113 @@ test('resolveSpriteFrames — résout depuis gameData.personnage_animations par
|
||||
// maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne
|
||||
// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent
|
||||
// désormais à [0, scene_width/height - sa propre taille].
|
||||
// Couverture de TOUTES les branches d'applyElementProperty() (39 -> lot 4
|
||||
// "modernisation JS", S3776) AVANT tout refactor, pour garantir que
|
||||
// l'extraction en sous-fonctions ne change aucun comportement — voir
|
||||
// CODE_QUALITY.md.
|
||||
test('applyElementProperty — "contenu" : texte littéral, null/undefined -> vidé', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'contenu', 'Bonjour');
|
||||
assert.equal(el.textContent, 'Bonjour');
|
||||
applyElementProperty(el, 'contenu', null);
|
||||
assert.equal(el.textContent, '');
|
||||
});
|
||||
|
||||
test('applyElementProperty — "surbrillance" : toggle/off/valeur par défaut (activer)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'surbrillance', 'toggle');
|
||||
assert.ok(el.classList.has('forgeHighlight'));
|
||||
applyElementProperty(el, 'surbrillance', 'toggle');
|
||||
assert.ok(!el.classList.has('forgeHighlight'));
|
||||
applyElementProperty(el, 'surbrillance', 'on');
|
||||
assert.ok(el.classList.has('forgeHighlight'));
|
||||
applyElementProperty(el, 'surbrillance', 'off');
|
||||
assert.ok(!el.classList.has('forgeHighlight'));
|
||||
});
|
||||
|
||||
test('applyElementProperty — "desactive" : toggle/off/valeur par défaut (désactiver)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'desactive', 'toggle');
|
||||
assert.ok(el.classList.has('forgeDisabled'));
|
||||
applyElementProperty(el, 'desactive', 'toggle');
|
||||
assert.ok(!el.classList.has('forgeDisabled'));
|
||||
applyElementProperty(el, 'desactive', 'on');
|
||||
assert.ok(el.classList.has('forgeDisabled'));
|
||||
applyElementProperty(el, 'desactive', 'off');
|
||||
assert.ok(!el.classList.has('forgeDisabled'));
|
||||
});
|
||||
|
||||
test('applyElementProperty — "visibilite" : toggle (selon état actuel), masque, invisible, valeur par défaut (visible)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'visibilite', 'toggle');
|
||||
assert.equal(el.style.display, 'none');
|
||||
applyElementProperty(el, 'visibilite', 'toggle');
|
||||
assert.equal(el.style.display, '');
|
||||
applyElementProperty(el, 'visibilite', 'masque');
|
||||
assert.equal(el.style.display, 'none');
|
||||
applyElementProperty(el, 'visibilite', 'invisible');
|
||||
assert.equal(el.style.display, '');
|
||||
assert.equal(el.style.visibility, 'hidden');
|
||||
applyElementProperty(el, 'visibilite', 'visible');
|
||||
assert.equal(el.style.display, '');
|
||||
assert.equal(el.style.visibility, '');
|
||||
});
|
||||
|
||||
test('applyElementProperty — "orientation" : toggle/gauche/valeur par défaut (droite)', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'orientation', 'toggle');
|
||||
assert.ok(el.classList.has('forgeFlipped'));
|
||||
applyElementProperty(el, 'orientation', 'toggle');
|
||||
assert.ok(!el.classList.has('forgeFlipped'));
|
||||
applyElementProperty(el, 'orientation', 'gauche');
|
||||
assert.ok(el.classList.has('forgeFlipped'));
|
||||
applyElementProperty(el, 'orientation', 'droite');
|
||||
assert.ok(!el.classList.has('forgeFlipped'));
|
||||
});
|
||||
|
||||
test('applyElementProperty — "pos_x_relatif"/"pos_y_relatif" : ajoute au pixel/pourcentage actuel, ignore une valeur non numérique', () => {
|
||||
const el = fakeElement();
|
||||
el.style.left = '10%';
|
||||
applyElementProperty(el, 'pos_x_relatif', '5');
|
||||
assert.equal(el.style.left, '15%');
|
||||
el.style.top = '20%';
|
||||
applyElementProperty(el, 'pos_y_relatif', '-5');
|
||||
assert.equal(el.style.top, '15%');
|
||||
applyElementProperty(el, 'pos_x_relatif', 'pas-un-nombre');
|
||||
assert.equal(el.style.left, '15%', 'valeur non numérique : aucun changement');
|
||||
});
|
||||
|
||||
test('applyElementProperty — propriété inconnue ou valeur null/undefined : aucun effet', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'propriete_qui_n_existe_pas', 'x');
|
||||
applyElementProperty(el, 'width', undefined);
|
||||
applyElementProperty(el, 'width', null);
|
||||
assert.equal(el.style.width, undefined, 'jamais touché');
|
||||
});
|
||||
|
||||
test('applyElementProperty — valeur "a||b" (va-et-vient) : bascule à chaque appel, persiste via dataset', () => {
|
||||
// Premier appel (dataset vide, "pas encore à la 2e valeur") -> bascule
|
||||
// sur la SECONDE valeur (parts[1]) et mémorise l'état "à la 2e" (voir
|
||||
// le code : atSecond=false -> next=parts[1], dataset mis à '1').
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'width', '10||90');
|
||||
assert.equal(el.style.width, '90%');
|
||||
assert.equal(el.dataset.togglewidth, '1');
|
||||
applyElementProperty(el, 'width', '10||90');
|
||||
assert.equal(el.style.width, '10%');
|
||||
assert.equal(el.dataset.togglewidth, '0');
|
||||
applyElementProperty(el, 'width', '10||90');
|
||||
assert.equal(el.style.width, '90%');
|
||||
});
|
||||
|
||||
test('applyElementProperty — valeur directe (pas de "||") : assignation simple avec suffixe', () => {
|
||||
const el = fakeElement();
|
||||
applyElementProperty(el, 'width', '42');
|
||||
assert.equal(el.style.width, '42%');
|
||||
applyElementProperty(el, 'bg_color', '#ff0000');
|
||||
assert.equal(el.style.backgroundColor, '#ff0000');
|
||||
});
|
||||
|
||||
function withScreensData(screenData, fn) {
|
||||
const previousScreens = global.screensData;
|
||||
const previousId = global.window.currentScreenId;
|
||||
@@ -97,6 +223,276 @@ function withScreensData(screenData, fn) {
|
||||
try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; }
|
||||
}
|
||||
|
||||
// Couverture de TOUTES les branches de runActionNode() (58 -> lot 4
|
||||
// "modernisation JS", S3776) AVANT tout refactor — un seul appel par
|
||||
// action_type, avec des dépendances globales (showScreen, fetch,
|
||||
// FORGE_PLAY_URLS, les fonctions forgeApply*Offline...) remplacées par
|
||||
// des espions qui enregistrent l'appel plutôt que par leur vraie
|
||||
// implémentation (déjà testée ailleurs : screens.js, apply-actions.js).
|
||||
// Objectif : garantir que l'extraction en sous-fonctions n'envoie pas le
|
||||
// mauvais argument ou n'appelle pas la mauvaise fonction — pas retester
|
||||
// le comportement de ces dépendances elles-mêmes.
|
||||
const ACTION_GLOBAL_KEYS = [
|
||||
'showScreen', 'screenIndexById', 'applyOpenRowBindings', 'bindClicks', 'bindHoverTexts', 'bindHoverTriggers',
|
||||
'refreshRuntimeData', 'forgeApplyDataActionOffline', 'forgeApplyVariableActionOffline',
|
||||
'forgeApplyScoreActionOffline', 'forgeApplyStatusActionOffline', 'forgeApplyAddRowActionOffline',
|
||||
'FORGE_PLAY_URLS', 'fetch', 'Audio',
|
||||
];
|
||||
|
||||
// `fn` peut être async (branches qui font un vrai fetch) — restaurer les
|
||||
// globales seulement APRÈS la résolution de son résultat (via .finally()
|
||||
// sur la promesse renvoyée, pas un await bloquant ici) est obligatoire :
|
||||
// un simple `try/finally` synchrone les restaurait pendant que la chaîne
|
||||
// .then() de runActionNode tournait encore (bug rencontré en écrivant ce
|
||||
// test : "refreshRuntimeData is not a function" alors que le stub était
|
||||
// bien posé — juste déjà retiré par le moment où le .then() s'exécutait).
|
||||
// Reste appelable SANS await pour les cas synchrones (pas de changement
|
||||
// de signature pour la majorité des tests déjà écrits).
|
||||
function withActionNodeGlobals(fn) {
|
||||
const calls = [];
|
||||
const previous = {};
|
||||
ACTION_GLOBAL_KEYS.forEach((key) => { previous[key] = global[key]; });
|
||||
const previousDispatch = global.window.dispatchGameEvent;
|
||||
const previousOffline = global.window.FORGE_OFFLINE;
|
||||
|
||||
global.showScreen = (id) => calls.push(['showScreen', id]);
|
||||
global.screenIndexById = (id) => global.screensData.findIndex((s) => s.id === id);
|
||||
global.applyOpenRowBindings = () => calls.push(['applyOpenRowBindings']);
|
||||
global.bindClicks = () => calls.push(['bindClicks']);
|
||||
global.bindHoverTexts = () => calls.push(['bindHoverTexts']);
|
||||
global.bindHoverTriggers = () => calls.push(['bindHoverTriggers']);
|
||||
global.window.dispatchGameEvent = (id) => calls.push(['dispatchGameEvent', id]);
|
||||
global.refreshRuntimeData = () => { calls.push(['refreshRuntimeData']); return Promise.resolve('refreshed'); };
|
||||
global.forgeApplyDataActionOffline = (gd, node) => calls.push(['forgeApplyDataActionOffline', node.id]);
|
||||
global.forgeApplyVariableActionOffline = (gd, node) => calls.push(['forgeApplyVariableActionOffline', node.id]);
|
||||
global.forgeApplyScoreActionOffline = (gd, node) => calls.push(['forgeApplyScoreActionOffline', node.id]);
|
||||
global.forgeApplyStatusActionOffline = (gd, node) => calls.push(['forgeApplyStatusActionOffline', node.id]);
|
||||
global.forgeApplyAddRowActionOffline = (gd, node) => { calls.push(['forgeApplyAddRowActionOffline', node.id]); return 4242; };
|
||||
global.FORGE_PLAY_URLS = {
|
||||
flowNodeRunData: '/run-data/999999',
|
||||
flowNodeRunVariable: '/run-variable/999999',
|
||||
flowNodeRunScore: '/run-score/999999',
|
||||
flowNodeRunStatus: '/run-status/999999',
|
||||
flowNodeRunAddRow: '/run-add-row/999999',
|
||||
};
|
||||
global.fetch = (url, opts) => {
|
||||
calls.push(['fetch', url, (opts && opts.method) || 'GET']);
|
||||
return Promise.resolve({ json: () => Promise.resolve({ row_id: 4343 }) });
|
||||
};
|
||||
global.Audio = function FakeAudio(src) {
|
||||
calls.push(['Audio', src]);
|
||||
return { play: () => ({ catch: () => {} }) };
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
ACTION_GLOBAL_KEYS.forEach((key) => { global[key] = previous[key]; });
|
||||
global.window.dispatchGameEvent = previousDispatch;
|
||||
global.window.FORGE_OFFLINE = previousOffline;
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = fn(calls);
|
||||
} catch (e) {
|
||||
restore();
|
||||
throw e;
|
||||
}
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.finally(restore);
|
||||
}
|
||||
restore();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('runActionNode — ecran_suivant : avance si un écran suivant existe, ne fait rien en fin de liste', () => {
|
||||
withScreensData({ id: 1 }, () => {
|
||||
global.screensData = [{ id: 1 }, { id: 2 }];
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_suivant' });
|
||||
assert.deepEqual(calls, [['showScreen', 2]]);
|
||||
});
|
||||
global.window.currentScreenId = 2;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_suivant' });
|
||||
assert.deepEqual(calls, [], 'déjà le dernier écran : aucun changement');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — ecran_precedent : recule si un écran précédent existe, ne fait rien en tête de liste', () => {
|
||||
global.screensData = [{ id: 1 }, { id: 2 }];
|
||||
global.window.currentScreenId = 2;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_precedent' });
|
||||
assert.deepEqual(calls, [['showScreen', 1]]);
|
||||
});
|
||||
global.window.currentScreenId = 1;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ecran_precedent' });
|
||||
assert.deepEqual(calls, [], 'déjà le premier écran : aucun changement');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — aller_a : montre l\'écran cible si présent, ignore sans target_screen_id', () => {
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'aller_a', target_screen_id: 7 });
|
||||
assert.deepEqual(calls, [['showScreen', 7]]);
|
||||
});
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'aller_a' });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — ouvrir_ligne : mémorise la ligne cliquée, affiche l\'écran, rebranche les écouteurs', () => {
|
||||
global.window.lastClickedRowId = 5;
|
||||
global.window.lastClickedDefinitionId = 9;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ouvrir_ligne', target_screen_id: 3 });
|
||||
assert.deepEqual(global.window.currentOpenRow, { definitionId: 9, rowId: 5 });
|
||||
assert.deepEqual(calls, [['showScreen', 3], ['applyOpenRowBindings'], ['bindClicks'], ['bindHoverTexts'], ['bindHoverTriggers']]);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_element : applique la propriété sur l\'élément trouvé, ignore si absent', () => {
|
||||
const el = fakeElement();
|
||||
global.document = { querySelector: (sel) => (sel.includes('"5"') ? el : null) };
|
||||
runActionNode({
|
||||
action_type: 'modifier_element', target_element_id: 5, element_property: 'contenu', element_value: 'Salut',
|
||||
});
|
||||
assert.equal(el.textContent, 'Salut');
|
||||
// Aucune erreur levée quand l'élément n'existe pas (id différent -> querySelector renvoie null).
|
||||
runActionNode({
|
||||
action_type: 'modifier_element', target_element_id: 999, element_property: 'contenu', element_value: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_objet_scene : applique la propriété sur l\'objet trouvé (par target_element_id ou target_object_id)', () => {
|
||||
const el = fakeElement();
|
||||
el.style.width = '10%';
|
||||
global.document = { querySelector: () => el };
|
||||
runActionNode({
|
||||
action_type: 'modifier_objet_scene', target_object_id: 8, element_property: 'contenu', element_value: 'Objet',
|
||||
});
|
||||
assert.equal(el.textContent, 'Objet');
|
||||
});
|
||||
|
||||
test('runActionNode — declencher_evenement : notifie l\'événement personnalisé ciblé', () => {
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'declencher_evenement', target_custom_event_id: 12 });
|
||||
assert.deepEqual(calls, [['dispatchGameEvent', 12]]);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — activer_onglet : montre l\'élément visé, masque ses frères directs, ignore les autres', () => {
|
||||
const elTarget = fakeElement();
|
||||
const elSibling = fakeElement();
|
||||
elSibling.style.display = '';
|
||||
const els = { 1: elTarget, 2: elSibling };
|
||||
global.document = { querySelector: (sel) => els[sel.match(/"(\d+)"/)[1]] };
|
||||
withScreensData({
|
||||
id: 1,
|
||||
elements: [{ id: 1, parent_id: null }, { id: 2, parent_id: null }, { id: 3, parent_id: 99 }],
|
||||
}, () => {
|
||||
runActionNode({ action_type: 'activer_onglet', target_element_id: 1 });
|
||||
});
|
||||
assert.equal(elTarget.style.display, '');
|
||||
assert.equal(elSibling.style.display, 'none');
|
||||
});
|
||||
|
||||
test('runActionNode — attendre : résout après le délai (data_value en secondes), 0 si non numérique', async () => {
|
||||
const t0 = Date.now();
|
||||
await runActionNode({ action_type: 'attendre', data_value: '0.01' });
|
||||
assert.ok(Date.now() - t0 >= 5, 'doit avoir réellement attendu');
|
||||
await runActionNode({ action_type: 'attendre', data_value: 'pas-un-nombre' });
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_donnee : hors ligne délègue à forgeApplyDataActionOffline, en ligne POST vers flowNodeRunData', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'modifier_donnee', id: 11 });
|
||||
assert.deepEqual(calls, [['forgeApplyDataActionOffline', 11], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'modifier_donnee', id: 999999 });
|
||||
assert.equal(calls[0][0], 'fetch');
|
||||
assert.equal(calls[0][1], '/run-data/999999');
|
||||
assert.equal(calls[0][2], 'POST');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_variable : hors ligne délègue à forgeApplyVariableActionOffline, en ligne POST vers flowNodeRunVariable', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'modifier_variable', id: 22 });
|
||||
assert.deepEqual(calls, [['forgeApplyVariableActionOffline', 22], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'modifier_variable', id: 999999 });
|
||||
assert.equal(calls[0][1], '/run-variable/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — modifier_score : hors ligne délègue à forgeApplyScoreActionOffline, en ligne POST vers flowNodeRunScore', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'modifier_score', id: 33 });
|
||||
assert.deepEqual(calls, [['forgeApplyScoreActionOffline', 33], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'modifier_score', id: 999999 });
|
||||
assert.equal(calls[0][1], '/run-score/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — definir_statut_partie : hors ligne délègue à forgeApplyStatusActionOffline, en ligne POST vers flowNodeRunStatus', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'definir_statut_partie', id: 44 });
|
||||
assert.deepEqual(calls, [['forgeApplyStatusActionOffline', 44], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'definir_statut_partie', id: 999999 });
|
||||
assert.equal(calls[0][1], '/run-status/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — ajouter_ligne : hors ligne mémorise l\'id renvoyé, en ligne POST puis mémorise json.row_id', async () => {
|
||||
global.window.FORGE_OFFLINE = true;
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'ajouter_ligne', id: 55 });
|
||||
assert.equal(global.window.lastInsertedRowId, 4242);
|
||||
assert.deepEqual(calls, [['forgeApplyAddRowActionOffline', 55], ['refreshRuntimeData']]);
|
||||
});
|
||||
global.window.FORGE_OFFLINE = false;
|
||||
await withActionNodeGlobals(async (calls) => {
|
||||
await runActionNode({ action_type: 'ajouter_ligne', id: 999999 });
|
||||
assert.equal(global.window.lastInsertedRowId, 4343);
|
||||
assert.equal(calls[0][1], '/run-add-row/999999');
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — jouer_son : joue le fichier audio (fire-and-forget), aucun effet sans data_value', () => {
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'jouer_son', data_value: '/bip.mp3' });
|
||||
assert.deepEqual(calls, [['Audio', '/bip.mp3']]);
|
||||
});
|
||||
withActionNodeGlobals((calls) => {
|
||||
runActionNode({ action_type: 'jouer_son' });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('runActionNode — action_type inconnu (ou "rien") : ne fait rien, renvoie une Promise résolue', async () => {
|
||||
const result = runActionNode({ action_type: 'rien' });
|
||||
assert.ok(result instanceof Promise);
|
||||
await result;
|
||||
});
|
||||
|
||||
test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => {
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
|
||||
@@ -41,6 +41,11 @@ function fakeEl(style) {
|
||||
return undefined;
|
||||
},
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; },
|
||||
// Getter (pas une valeur figée à la construction) : recalculé à
|
||||
// chaque lecture, comme le vrai `.dataset`, puisque `_objectId` est
|
||||
// posé APRÈS coup par les tests (voir plus bas) — un `dataset: {}`
|
||||
// gelé au moment de fakeEl() serait toujours vide.
|
||||
get dataset() { return { objectId: this._objectId }; },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function fakeParentEl() {
|
||||
appendChild(child) { children.push(child); },
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/data-for-object-id="(\d+)"/);
|
||||
return m ? children.find((c) => c._forObjectId === m[1]) : undefined;
|
||||
return m ? children.find((c) => c.dataset.forObjectId === m[1]) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,12 +53,24 @@ function fakeEl(parentEl) {
|
||||
};
|
||||
}
|
||||
|
||||
// Un vrai `.dataset` (DOMStringMap) convertit toujours la valeur assignée
|
||||
// en chaîne — reproduit ici via un Proxy, sinon `bubble.dataset.forObjectId
|
||||
// = objectId` avec un objectId numérique (cas réel : obj.id vient de la
|
||||
// base) resterait un number, et ne correspondrait plus jamais à la
|
||||
// capture de regex (toujours une string) dans fakeParentEl.querySelector().
|
||||
function makeDataset() {
|
||||
const store = {};
|
||||
return new Proxy(store, {
|
||||
set(target, key, value) { target[key] = String(value); return true; },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeBubbleEl() {
|
||||
return {
|
||||
className: 'collisionInteractBubble',
|
||||
style: {},
|
||||
textContent: '',
|
||||
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
|
||||
dataset: makeDataset(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,24 @@ const {
|
||||
forgeQuizResultQuit,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
// Un vrai `.dataset` (DOMStringMap) lit/écrit les MÊMES attributs qu'un
|
||||
// `getAttribute('data-foo-bar')`/`setAttribute(...)`, juste sous forme
|
||||
// camelCase (`dataset.fooBar`) — ce Proxy reflète les deux dans le MÊME
|
||||
// objet `attrs` passé en argument, pour que les mocks déjà construits par
|
||||
// clé kebab-case (ex. `{ 'data-timer-mode': 'question' }`, déjà utilisé
|
||||
// par plusieurs tests de ce fichier) continuent de marcher sans y
|
||||
// toucher, tout en exposant `.dataset` comme le vrai DOM (voir
|
||||
// dialogue-box-controller.js après le lot 4 "modernisation JS", S7761).
|
||||
function kebabDataKey(camelProp) {
|
||||
return `data-${camelProp.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`;
|
||||
}
|
||||
function makeDatasetFromAttrs(attrs) {
|
||||
return new Proxy({}, {
|
||||
get(_, prop) { return attrs[kebabDataKey(prop)]; },
|
||||
set(_, prop, value) { attrs[kebabDataKey(prop)] = String(value); return true; },
|
||||
});
|
||||
}
|
||||
|
||||
function fakeWidget(objectId) {
|
||||
const roles = {
|
||||
header: { textContent: '' },
|
||||
@@ -27,8 +45,10 @@ function fakeWidget(objectId) {
|
||||
footer: { innerHTML: '' },
|
||||
};
|
||||
const classes = new Set();
|
||||
const attrs = { 'data-object-id': String(objectId) };
|
||||
return {
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||
dataset: makeDatasetFromAttrs(attrs),
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
@@ -57,7 +77,7 @@ function fakeWidget(objectId) {
|
||||
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
|
||||
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
|
||||
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
|
||||
const screenEl = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) };
|
||||
const screenEl = { dataset: { screenId: '7' } };
|
||||
const attrs = {};
|
||||
const classes = new Set();
|
||||
const bodyChildren = [];
|
||||
@@ -65,7 +85,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
||||
const widget = {
|
||||
parentNode: null,
|
||||
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
dataset: makeDatasetFromAttrs(attrs),
|
||||
classList: { add: (c) => classes.add(c) },
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
@@ -74,7 +94,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et r
|
||||
forgeEscapeCameraForFullscreen(widget);
|
||||
assert.equal(bodyChildren[0], widget);
|
||||
assert.equal(widget.parentNode, body);
|
||||
assert.equal(attrs['data-owner-screen-id'], '7');
|
||||
assert.equal(widget.dataset.ownerScreenId, '7');
|
||||
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -288,6 +308,7 @@ function fakeQuizChoiceBtn() {
|
||||
|
||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
attrs = attrs || {};
|
||||
attrs['data-object-id'] = String(objectId);
|
||||
const roles = {
|
||||
header: { textContent: '' },
|
||||
question: { textContent: '' },
|
||||
@@ -312,13 +333,10 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
||||
// un widget "posé sur l'écran courant" est toujours, au départ,
|
||||
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
|
||||
closest: (sel) => (sel === '.playScreen' ? { getAttribute: (a) => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
|
||||
getAttribute: (attr) => {
|
||||
if (attr === 'data-object-id') return String(objectId);
|
||||
if (attr in attrs) return attrs[attr];
|
||||
return null;
|
||||
},
|
||||
closest: (sel) => (sel === '.playScreen' ? { dataset: { screenId: String(window.currentScreenId) } } : null),
|
||||
getAttribute: (attr) => (attr in attrs ? attrs[attr] : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
dataset: makeDatasetFromAttrs(attrs),
|
||||
classList: {
|
||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
||||
@@ -600,7 +618,6 @@ test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jama
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
@@ -948,7 +965,7 @@ test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "t
|
||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
dataset: { objectId: '99' },
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
@@ -979,7 +996,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
dataset: { objectId: '99' },
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
|
||||
@@ -34,6 +34,7 @@ function fakeMangaWidget() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
dataset: { objectId: '42' },
|
||||
querySelector(sel) {
|
||||
if (sel === '[data-quiz-role="header"]') return header;
|
||||
if (sel === '[data-manga-role="progress"]') return progress;
|
||||
@@ -139,6 +140,7 @@ function fakeClassiqueWidget() {
|
||||
const progress = { innerHTML: '' };
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
dataset: { objectId: '42' },
|
||||
querySelector(sel) {
|
||||
if (sel === '[data-quiz-role="header"]') return header;
|
||||
if (sel === '[data-classique-role="progress"]') return progress;
|
||||
@@ -182,6 +184,7 @@ function fakeClassiqueWidgetForResult() {
|
||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
dataset: { objectId: '42' },
|
||||
querySelector(sel) {
|
||||
if (sel === '.classiqueContent') return content;
|
||||
if (sel === '[data-classique-role="result"]') return resultZone;
|
||||
|
||||
+363
-264
@@ -20,104 +20,130 @@ const ELEMENT_PROPERTY_CSS = {
|
||||
pos_y: { css: 'top', suffix: '%' },
|
||||
};
|
||||
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme pour
|
||||
// les autres réglages ci-dessous, cible n'importe quel élément — pointer
|
||||
// ceci vers un conteneur remplacerait tout son contenu imbriqué, à éviter.
|
||||
function applyContenuProperty(targetEl, value) {
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
}
|
||||
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans poser
|
||||
// de bordure colorée togglée à la main — utile pour un mentor qui attire
|
||||
// l'attention du joueur vers l'élément à toucher ensuite.
|
||||
function applySurbrillanceProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT de
|
||||
// "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au clic"
|
||||
// éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
function applyDesactiveProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
}
|
||||
|
||||
function applyVisibiliteProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue de
|
||||
// dos" pour chaque sens de déplacement.
|
||||
function applyOrientationProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||
// toujours au même endroit.
|
||||
function applyRelativePositionProperty(targetEl, property, value) {
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = Number.parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = Number.parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
}
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
function applyToggleValueProperty(targetEl, property, spec, parts) {
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
}
|
||||
|
||||
function applyElementProperty(targetEl, property, value) {
|
||||
if (property === 'contenu') {
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||||
// imbriqué, à éviter.
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
applyContenuProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'surbrillance') {
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
||||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
||||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
applySurbrillanceProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'desactive') {
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
||||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
||||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
applyDesactiveProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'visibilite') {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
applyVisibiliteProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'orientation') {
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
|
||||
// de dos" pour chaque sens de déplacement.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
applyOrientationProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||
// toujours au même endroit.
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
applyRelativePositionProperty(targetEl, property, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||
if (!spec || value === undefined || value === null) return;
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
if (typeof value === 'string' && value.includes('||')) {
|
||||
applyToggleValueProperty(targetEl, property, spec, value.split('||'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,8 +164,8 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// risquer un blocage à 0.
|
||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
if (screenData?.scene_width == null) return rawValue;
|
||||
const size = Number.parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||||
@@ -155,206 +181,278 @@ function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
function applyObjectProperty(targetEl, property, value) {
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||
const delta = parseFloat(value);
|
||||
const delta = Number.parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[css]) || 0;
|
||||
const current = Number.parseFloat(targetEl.style[css]) || 0;
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x' || property === 'pos_y') {
|
||||
if (value === undefined || value === null) return;
|
||||
const css = property === 'pos_x' ? 'left' : 'top';
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0)}px`;
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, Number.parseFloat(value) || 0)}px`;
|
||||
return;
|
||||
}
|
||||
applyElementProperty(targetEl, property, value);
|
||||
}
|
||||
|
||||
function runActionNode(node) {
|
||||
if (node.action_type === 'ecran_suivant') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||||
// précise.
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir
|
||||
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
||||
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
||||
// render_scene_object.py), donc le même sélecteur suffit.
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||
// signaler l'événement, jamais choisir un élément — c'est à
|
||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
||||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'attendre') {
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du
|
||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
const seconds = parseFloat(node.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
} if (node.action_type === 'modifier_donnee') {
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
|
||||
// aucun serveur disponible dans le paquet exporté — la même mutation
|
||||
// s'applique directement sur gameData, en mémoire, dans le navigateur.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyDataActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
||||
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
||||
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
|
||||
// sinon, donc toujours transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'modifier_variable') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'modifier_score') {
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyScoreActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'definir_statut_partie') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'ajouter_ligne') {
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
|
||||
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
|
||||
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
|
||||
// "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
} if (node.action_type === 'jouer_son') {
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
|
||||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
||||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try {
|
||||
spriteData = JSON.parse(node.data_value || '{}');
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||
// reste silencieusement absente (spriteData reste {}).
|
||||
console.warn('configuration sprite invalide', e);
|
||||
}
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 'rien' : aucune action volontairement.
|
||||
function runEcranSuivantAction() {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runEcranPrecedentAction() {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runAllerAAction(node) {
|
||||
if (!node.target_screen_id) return Promise.resolve();
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette action
|
||||
// (voir bindClicks dans triggers.js) puis affiche l'écran de détail avec
|
||||
// ses placeholders "champ" résolus contre cette ligne précise.
|
||||
function runOuvrirLigneAction(node) {
|
||||
if (!node.target_screen_id) return Promise.resolve();
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runModifierElementAction(node) {
|
||||
if (!node.target_element_id) return Promise.resolve();
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir applyObjectProperty()
|
||||
// plus bas) plutôt qu'en %. data-element-id est posé sur CHAQUE objet de
|
||||
// scène en plus de data-object-id (voir render_scene_object.py), donc le
|
||||
// même sélecteur suffit.
|
||||
function runModifierObjetSceneAction(node) {
|
||||
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que signaler
|
||||
// l'événement, jamais choisir un élément — c'est à l'écouteur (déclencheur
|
||||
// → condition → action) de décider quoi faire, avec ses propres réglages
|
||||
// habituels.
|
||||
function runDeclencherEvenementAction(node) {
|
||||
if (!node.target_custom_event_id) return Promise.resolve();
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id, y
|
||||
// compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par frère.
|
||||
// screenData.elements contient TOUS les éléments de l'écran (imbriqués
|
||||
// compris, voir list_elements côté serveur), donc ça fonctionne aussi
|
||||
// bien pour des onglets imbriqués dans un même conteneur que pour des
|
||||
// "vues" posées directement sur l'écran.
|
||||
function runActiverOngletAction(node) {
|
||||
if (!node.target_element_id) return Promise.resolve();
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du joueur
|
||||
// (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
function runAttendreAction(node) {
|
||||
const seconds = Number.parseFloat(node.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) : aucun
|
||||
// serveur disponible dans le paquet exporté — la même mutation s'applique
|
||||
// directement sur gameData, en mémoire, dans le navigateur.
|
||||
function runModifierDonneeAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyDataActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur SEULEMENT
|
||||
// si ce nœud vise respectivement CLICKED_ROW_ID ou LAST_INSERTED_ROW_ID
|
||||
// (voir flow_node_run_data.py) — sans effet sinon, donc toujours
|
||||
// transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
function runModifierVariableAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||
function runModifierScoreAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyScoreActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
function runDefinirStatutPartieAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire hors
|
||||
// ligne) ; son id devient window.lastInsertedRowId pour que les nœuds
|
||||
// "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕
|
||||
// Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||
function runAjouterLigneAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la musique
|
||||
// de fond de l'écran (runScreenBackgroundMusic() dans screens.js, qui
|
||||
// elle boucle et s'arrête au changement d'écran). Fire-and-forget : ne
|
||||
// bloque jamais la suite du graphe.
|
||||
function runJouerSonAction(node) {
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runJouerAnimationSpriteAction(node) {
|
||||
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try {
|
||||
spriteData = JSON.parse(node.data_value || '{}');
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||
// reste silencieusement absente (spriteData reste {}).
|
||||
console.warn('configuration sprite invalide', e);
|
||||
}
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Dispatch par action_type — chaque fonction ci-dessus vérifie ELLE-MÊME
|
||||
// ses conditions supplémentaires (ex. target_screen_id présent) et
|
||||
// renvoie une Promise résolue sans effet si elles ne sont pas remplies ;
|
||||
// ce dispatcher n'a donc plus qu'une seule décision (la présence d'un
|
||||
// handler), au lieu d'empiler ici les conditions de chaque branche
|
||||
// (c'était la source du score S3776 restant à 23 après la 1re extraction
|
||||
// en sous-fonctions — un enchaînement de if, même plat, coûte un point
|
||||
// par condition ET par opérateur booléen combiné).
|
||||
// Clés QUOTÉES délibérément (pas juste un style) : plusieurs tests Python
|
||||
// vérifient la présence du littéral quoté ("'jouer_son'" in js, etc.,
|
||||
// voir tests/test_sound.py, tests/test_sprite_animation.py,
|
||||
// tests/test_flow.py) comme preuve que ce action_type est bien implémenté
|
||||
// côté runtime — une clé nue (jouer_son: ...) fait disparaître ce
|
||||
// littéral du bundle JS sans rien changer au comportement, mais casse ces
|
||||
// tests. Gardé quoté pour rester détectable de la même façon.
|
||||
const ACTION_NODE_HANDLERS = {
|
||||
ecran_suivant: runEcranSuivantAction,
|
||||
ecran_precedent: runEcranPrecedentAction,
|
||||
aller_a: runAllerAAction,
|
||||
'ouvrir_ligne': runOuvrirLigneAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||
modifier_element: runModifierElementAction,
|
||||
modifier_objet_scene: runModifierObjetSceneAction,
|
||||
declencher_evenement: runDeclencherEvenementAction,
|
||||
activer_onglet: runActiverOngletAction,
|
||||
attendre: runAttendreAction,
|
||||
modifier_donnee: runModifierDonneeAction,
|
||||
modifier_variable: runModifierVariableAction,
|
||||
modifier_score: runModifierScoreAction,
|
||||
definir_statut_partie: runDefinirStatutPartieAction,
|
||||
ajouter_ligne: runAjouterLigneAction,
|
||||
'jouer_son': runJouerSonAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||
'jouer_animation_sprite': runJouerAnimationSpriteAction, // eslint-disable-line quote-props -- idem
|
||||
};
|
||||
|
||||
function runActionNode(node) {
|
||||
const handler = ACTION_NODE_HANDLERS[node.action_type];
|
||||
// 'rien' (ou tout action_type inconnu) : aucune action volontairement.
|
||||
return handler ? handler(node) : Promise.resolve();
|
||||
}
|
||||
|
||||
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
||||
@@ -382,7 +480,7 @@ const activeSpriteAnimations = new Map();
|
||||
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||||
// les nœuds/clips déjà créés avant ce changement.
|
||||
function resolveSpriteFrames(elementId, spriteData) {
|
||||
const byAnimation = (gameData.personnage_animations || {})[elementId];
|
||||
const byAnimation = gameData.personnage_animations?.[elementId];
|
||||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||||
}
|
||||
@@ -418,7 +516,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
const loop = !!spriteData.loop;
|
||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||
const active = activeSpriteAnimations.get(targetEl);
|
||||
if (active && active.signature === signature) return;
|
||||
if (active?.signature === signature) return;
|
||||
if (active && !active.loop) return;
|
||||
if (active) {
|
||||
clearInterval(active.intervalId);
|
||||
@@ -461,6 +559,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
runSpriteAnimation,
|
||||
stopAllSpriteAnimations,
|
||||
activeSpriteAnimations,
|
||||
applyElementProperty,
|
||||
applyObjectProperty,
|
||||
clampSceneObjectPosition,
|
||||
runActionNode,
|
||||
|
||||
+32
-30
@@ -20,6 +20,21 @@
|
||||
const PLACEHOLDER_OPEN = '{' + '{';
|
||||
const PLACEHOLDER_CLOSE = '}' + '}';
|
||||
|
||||
// Partagée entre applyOpenRowBindings() et _forgeApplyFreshRuntimeData()
|
||||
// (même algorithme, dupliqué à l'identique dans les deux avant ce
|
||||
// refactor S4144) : `byId` explicite en paramètre plutôt que capturé par
|
||||
// fermeture, puisque chaque appelant construit sa PROPRE table (éléments
|
||||
// de l'écran au moment de son propre appel, jamais partagés entre les
|
||||
// deux).
|
||||
function isDescendantOfElement(byId, e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyOpenRowBindings() {
|
||||
if (!window.currentOpenRow) return;
|
||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||
@@ -53,19 +68,11 @@ function applyOpenRowBindings() {
|
||||
const byId = {};
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
function hasUnresolvedPlaceholder(e) {
|
||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
return !!e.rendered_html?.includes(PLACEHOLDER_OPEN);
|
||||
}
|
||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||
const targets = candidates.filter(
|
||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)),
|
||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)),
|
||||
);
|
||||
|
||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||
@@ -92,7 +99,7 @@ function applyOpenRowBindings() {
|
||||
const toSplit = [];
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
||||
if (node.nodeValue.includes(PLACEHOLDER_OPEN)) toSplit.push(node);
|
||||
}
|
||||
toSplit.forEach((textNode) => {
|
||||
const raw = textNode.nodeValue;
|
||||
@@ -125,9 +132,12 @@ function applyOpenRowBindings() {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
||||
const bindKey = `data-bind-attr-${attr.name}`;
|
||||
const template = node.hasAttribute(bindKey)
|
||||
? node.getAttribute(bindKey)
|
||||
: (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
let template = null;
|
||||
if (node.hasAttribute(bindKey)) {
|
||||
template = node.getAttribute(bindKey);
|
||||
} else if (attr.value.includes(PLACEHOLDER_OPEN)) {
|
||||
template = attr.value;
|
||||
}
|
||||
if (template === null) return;
|
||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||
placeholderRe.lastIndex = 0;
|
||||
@@ -227,19 +237,11 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
const byId = {};
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
const hasMarker = (e) => !!(e.rendered_html && (
|
||||
e.rendered_html.indexOf('repeaterItem') !== -1
|
||||
|| e.rendered_html.indexOf('jaugeBar') !== -1
|
||||
|| e.rendered_html.indexOf('visibilityGated') !== -1
|
||||
|| e.rendered_html.indexOf('dataBound') !== -1
|
||||
e.rendered_html.includes('repeaterItem')
|
||||
|| e.rendered_html.includes('jaugeBar')
|
||||
|| e.rendered_html.includes('visibilityGated')
|
||||
|| e.rendered_html.includes('dataBound')
|
||||
));
|
||||
const isDescendantOf = (e, ancestorId) => {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||
// HTML juste après la balise concernée (voir _mark() dans
|
||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
||||
@@ -254,13 +256,13 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||
// de premier niveau existant (l'exemplaire) que parce que son
|
||||
// HTML est imbriqué dans le sien.
|
||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
||||
const COMMENT_MARKERS = new Set(['visibilityGated', 'dataBound']);
|
||||
const findCommentMarkedDescendants = (root) => {
|
||||
const found = [];
|
||||
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
||||
const sib = node.nextSibling;
|
||||
const hasCommentMarker = !!(
|
||||
sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1
|
||||
sib?.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.has(sib.data)
|
||||
);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
@@ -282,7 +284,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
};
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates
|
||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)))
|
||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOfElement(byId, other, e.id)))
|
||||
.forEach((el) => {
|
||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||
if (!target) return;
|
||||
@@ -299,7 +301,7 @@ function _forgeApplyFreshRuntimeData(newData) {
|
||||
// disparaître ni réapparaître juste parce qu'un texte à
|
||||
// l'intérieur a changé.
|
||||
innerMarked.forEach((innerNode) => {
|
||||
const innerId = innerNode.getAttribute('data-element-id');
|
||||
const innerId = innerNode.dataset.elementId;
|
||||
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
|
||||
@@ -25,10 +25,10 @@ function forgeCollisionRuleStateKey(objectId, ruleIndex) {
|
||||
}
|
||||
|
||||
function forgeElementBoxRect(el, collision) {
|
||||
const left = parseFloat(el.style.left) || 0;
|
||||
const top = parseFloat(el.style.top) || 0;
|
||||
const width = parseFloat(el.style.width) || 0;
|
||||
const height = parseFloat(el.style.height) || 0;
|
||||
const left = Number.parseFloat(el.style.left) || 0;
|
||||
const top = Number.parseFloat(el.style.top) || 0;
|
||||
const width = Number.parseFloat(el.style.width) || 0;
|
||||
const height = Number.parseFloat(el.style.height) || 0;
|
||||
return forgeCollisionRectFromBox({
|
||||
left, top, width, height,
|
||||
}, collision);
|
||||
@@ -86,7 +86,7 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionInteractBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
bubble.dataset.forObjectId = objectId;
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
@@ -112,8 +112,8 @@ function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
|
||||
}
|
||||
|
||||
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
const parentEl = objectEl && objectEl.parentElement;
|
||||
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
const parentEl = objectEl?.parentElement;
|
||||
const bubble = parentEl?.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
bubble.dataset.forObjectId = objectId;
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
@@ -139,7 +139,7 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
||||
const parentEl = targetEl && targetEl.parentElement;
|
||||
const parentEl = targetEl?.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
const objRect = forgeElementBoxRect(targetEl);
|
||||
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
@@ -153,85 +153,114 @@ function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines —
|
||||
// chaque occurrence de "Déclencher ce dialogue" porte le sien, voir
|
||||
// screens/rendering/collision_rules.py) dans le widget "💬 Boîte de
|
||||
// dialogue" posé sur l'écran (voir static/js/play/dialogue-box-
|
||||
// controller.js) — sans effet si aucun widget n'est posé, comme le reste
|
||||
// de ce moteur. Le nom affiché (en-tête du quiz, xAPI) est celui de
|
||||
// l'objet déclencheur (voir "ℹ️ Informations") — plus de "quête" pour le
|
||||
// porter. mark_completed (optionnel) : marque ce dialogue terminé une
|
||||
// fois épuisé. `action.then` (chaînage, voir maquette validée : "pouvoir
|
||||
// modifier un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur —
|
||||
// transmis à forgeShowDialogueBox, exécuté par dialogue-box-controller.js
|
||||
// une fois le dialogue épuisé.
|
||||
function runDialogueCollisionAction(action, targetEl, obj) {
|
||||
const lines = action.dialogue?.lines || [];
|
||||
const dialogueId = action.dialogue?.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
}
|
||||
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en export,
|
||||
// donc aucun branchement FORGE_OFFLINE nécessaire, même philosophie que
|
||||
// le reste de ce fichier). Instantané : `then` s'exécute tout de suite,
|
||||
// contrairement à un dialogue.
|
||||
function runVariableCollisionAction(action, targetEl, obj) {
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js), même
|
||||
// comparaison qu'un nœud Condition de l'éditeur de logique, sur une
|
||||
// VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet ici
|
||||
// (voir maquette validée : le losange ne teste qu'une variable).
|
||||
function runConditionCollisionAction(action, targetEl, obj) {
|
||||
const isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
const branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
}
|
||||
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas forcément
|
||||
// celui qui porte le déclencheur) — réutilise TEL QUEL applyObjectProperty
|
||||
// (static/js/play/actions.js, équivalent scène 2D d'applyElementProperty),
|
||||
// plutôt que de dupliquer cette logique. Partagée entre "surbrillance" et
|
||||
// "visibilite", qui suivent exactement le même patron.
|
||||
function runSurbrillanceOuVisibiliteCollisionAction(action, targetEl, obj) {
|
||||
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané : `then`
|
||||
// s'exécute tout de suite, sans attendre la fin du son.
|
||||
function runSonCollisionAction(action, targetEl, obj) {
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit qu'un
|
||||
// dialogue ci-dessus.
|
||||
function runVideoCollisionAction(action, targetEl, obj) {
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
}
|
||||
|
||||
function runIndicationCollisionAction(action, targetEl, obj) {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}
|
||||
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même vocabulaire
|
||||
// que l'action "attendre" du Flow (static/js/play/actions.js), utile ex.
|
||||
// pour laisser un temps de lecture après une narration à l'affichage de
|
||||
// l'écran.
|
||||
function runAttendreCollisionAction(action, targetEl, obj) {
|
||||
const seconds = Number.parseFloat(action.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(() => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
const COLLISION_RULE_ACTION_HANDLERS = {
|
||||
dialogue: runDialogueCollisionAction,
|
||||
variable: runVariableCollisionAction,
|
||||
condition: runConditionCollisionAction,
|
||||
surbrillance: runSurbrillanceOuVisibiliteCollisionAction,
|
||||
visibilite: runSurbrillanceOuVisibiliteCollisionAction,
|
||||
son: runSonCollisionAction,
|
||||
video: runVideoCollisionAction,
|
||||
indication: runIndicationCollisionAction,
|
||||
attendre: runAttendreCollisionAction,
|
||||
};
|
||||
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
// (affichage de bulle + attente de touche).
|
||||
function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
if (action.type === 'dialogue') {
|
||||
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
|
||||
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
|
||||
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
|
||||
// de dialogue" posé sur l'écran (voir static/js/play/
|
||||
// dialogue-box-controller.js) — sans effet si aucun widget n'est
|
||||
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
|
||||
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
|
||||
// Informations") — plus de "quête" pour le porter. mark_completed
|
||||
// (optionnel) : marque ce dialogue terminé une fois épuisé.
|
||||
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||
// controller.js une fois le dialogue épuisé.
|
||||
const lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
const dialogueId = action.dialogue && action.dialogue.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
} else if (action.type === 'variable') {
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
// "Modifier une variable" du graphe de logique document (voir
|
||||
// screens/data_actions/apply_variable_action.py), portée ici par
|
||||
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
|
||||
// TOUJOURS chargée — voir templates/play.html — pas seulement en
|
||||
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
|
||||
// philosophie que le reste de ce fichier). Instantané : `then`
|
||||
// s'exécute tout de suite, contrairement à un dialogue.
|
||||
forgeApplyVariableActionOffline(gameData, action);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'condition') {
|
||||
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
|
||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
||||
const isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
const branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
||||
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'son') {
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'video') {
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
||||
// qu'un dialogue ci-dessus.
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'attendre') {
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même
|
||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
||||
// narration à l'affichage de l'écran.
|
||||
const seconds = parseFloat(action.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(() => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
const handler = COLLISION_RULE_ACTION_HANDLERS[action.type];
|
||||
if (handler) handler(action, targetEl, obj);
|
||||
}
|
||||
|
||||
function forgeCollisionRuleTick(screenData) {
|
||||
@@ -242,7 +271,7 @@ function forgeCollisionRuleTick(screenData) {
|
||||
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||
@@ -261,7 +290,7 @@ function forgeCollisionRuleTick(screenData) {
|
||||
if (rule.action.type === 'interagir') {
|
||||
if (isActive) {
|
||||
anyInteractActive = true;
|
||||
forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E');
|
||||
forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, player.personnage_commandes?.interagir || 'E');
|
||||
}
|
||||
forgeCollisionRuleActiveState[stateKey] = isActive;
|
||||
return;
|
||||
@@ -280,10 +309,10 @@ function forgeCollisionRuleTick(screenData) {
|
||||
function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
activeCollisionRuleInteractListener = function (ev) {
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
||||
if (!player?.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach((rule, ruleIndex) => {
|
||||
@@ -314,7 +343,7 @@ function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach((rule) => {
|
||||
@@ -347,11 +376,11 @@ function forgeStopCollisionRuleControllers() {
|
||||
// ET au moins un objet avec des règles.
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
if (screenData?.kind !== 'jeu_2d') return;
|
||||
const elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasRules = elements.some((o) => o.collision_rules && o.collision_rules.length);
|
||||
const hasRules = elements.some((o) => o.collision_rules?.length);
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
||||
forgeStartCollisionRuleInteractListener(screenData);
|
||||
|
||||
@@ -43,8 +43,8 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
const e = normalizeBool(expected);
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual); const
|
||||
en = parseFloat(expected);
|
||||
const an = Number.parseFloat(actual); const
|
||||
en = Number.parseFloat(expected);
|
||||
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
||||
if (numeric) {
|
||||
switch (operator) {
|
||||
@@ -87,7 +87,7 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||
// déroulant, voir screen_edit.html).
|
||||
function readVariableValue(name) {
|
||||
const v = (gameData.variables || {})[name];
|
||||
const v = gameData.variables?.[name];
|
||||
return v ? v.value : undefined;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function resolveVariablePath(rawValue, path) {
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
||||
current = m[1] !== undefined ? current[m[1]] : current[Number.parseInt(m[2], 10)];
|
||||
}
|
||||
return current === undefined ? null : current;
|
||||
}
|
||||
@@ -129,13 +129,13 @@ function resolveVariablePath(rawValue, path) {
|
||||
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
|
||||
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
|
||||
function forgeCollisionRectFromBox(box, collision) {
|
||||
const width = (collision && collision.width) || box.width;
|
||||
const height = (collision && collision.height) || box.height;
|
||||
const offsetX = (collision && collision.offset_x) || 0;
|
||||
const offsetY = (collision && collision.offset_y) || 0;
|
||||
const width = collision?.width || box.width;
|
||||
const height = collision?.height || box.height;
|
||||
const offsetX = collision?.offset_x || 0;
|
||||
const offsetY = collision?.offset_y || 0;
|
||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
||||
const shape = (collision && collision.shape) || 'rectangle';
|
||||
const shape = collision?.shape || 'rectangle';
|
||||
return {
|
||||
left, top, right: left + width, bottom: top + height, width, height, shape,
|
||||
};
|
||||
@@ -156,7 +156,7 @@ function forgeCirclesOverlap(a, b) {
|
||||
br = Math.min(b.width, b.height) / 2;
|
||||
const dx = acx - bcx; const
|
||||
dy = acy - bcy;
|
||||
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
|
||||
return Math.hypot(dx, dy) < (ar + br);
|
||||
}
|
||||
|
||||
// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX
|
||||
@@ -190,13 +190,13 @@ function elementsOverlap(elementIdA, elementIdB) {
|
||||
const objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
||||
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
||||
if (
|
||||
(objA && objA.collision && objA.collision.enabled === false)
|
||||
|| (objB && objB.collision && objB.collision.enabled === false)
|
||||
objA?.collision?.enabled === false
|
||||
|| objB?.collision?.enabled === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
|
||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA?.collision);
|
||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB?.collision);
|
||||
return forgeShapesOverlap(rectA, rectB);
|
||||
}
|
||||
|
||||
@@ -215,9 +215,9 @@ function evaluateConditionClause(clause) {
|
||||
if (source === 'variable') {
|
||||
const varName = clause.variable ?? clause.cond_variable;
|
||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||
const varInfo = (gameData.variables || {})[varName];
|
||||
const varInfo = gameData.variables?.[varName];
|
||||
const actual = resolveVariablePath(readVariableValue(varName), path);
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
const fieldType = varInfo?.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(
|
||||
|
||||
@@ -56,8 +56,8 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// ---------- Boîte de dialogue ----------
|
||||
@@ -141,7 +141,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
||||
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'));
|
||||
if (screenEl) widget.dataset.ownerScreenId = screenEl.dataset.screenId;
|
||||
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||
document.body.appendChild(widget);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
widget.classList.remove('is-active', 'is-fullscreen');
|
||||
const objectId = widget.getAttribute('data-object-id');
|
||||
const objectId = widget.dataset.objectId;
|
||||
forgeStopQuizTimer(objectId);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ const forgeQuizTimerState = {};
|
||||
|
||||
function forgeStopQuizTimer(objectId) {
|
||||
const t = forgeQuizTimerState[objectId];
|
||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
||||
if (t?.intervalId) clearInterval(t.intervalId);
|
||||
delete forgeQuizTimerState[objectId];
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
for (const value of values) value.textContent = window.forgeQuizScore;
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ function forgeUpdateAllScoreWidgets() {
|
||||
// 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}`;
|
||||
for (const value of values) value.textContent = `Score : ${window.forgeQuizScore}`;
|
||||
}
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
@@ -341,7 +341,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// 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}`);
|
||||
const stateKey = widget ? widget.dataset.objectId : (`_sansBoiteDialogue_${dialogueId}`);
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
@@ -400,7 +400,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
// 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;
|
||||
if (!state || state.lines[state.index]?.type === 'question') return;
|
||||
forgeAdvanceConversation(objectId);
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
const previousState = forgeQuizBoxState[quizObjectId];
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId,
|
||||
@@ -456,14 +456,14 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// 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';
|
||||
const isFullscreen = widget.dataset.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;
|
||||
const timerMode = widget.dataset.timerMode;
|
||||
const timerSeconds = Number.parseInt(widget.dataset.timerSeconds, 10) || 0;
|
||||
if (timerMode === 'question') {
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||
@@ -477,7 +477,17 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// 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;
|
||||
// Lecture volontairement sans effet apparent : force le navigateur à
|
||||
// calculer la mise en page AVANT le .add() ci-dessous (sans ça, les deux
|
||||
// changements de classe sont fusionnés en une seule passe de rendu et
|
||||
// l'animation ne redémarre jamais). `void` est la forme la plus claire
|
||||
// pour marquer une expression délibérément ignorée (accepté par ESLint,
|
||||
// no-unused-expressions désactivé dans ce projet) — Sonar n'a aucun
|
||||
// idiome qu'il reconnaisse pour ce cas : S3735 (void), S905 (expression
|
||||
// seule sans void) et S1481 (variable jamais lue) se sont succédé sur
|
||||
// les 3 formes essayées, chacune corrigeant la précédente en en créant
|
||||
// une autre. NOSONAR ici plutôt que de continuer, voir CODE_QUALITY.md.
|
||||
void widget.offsetWidth; // NOSONAR S3735 - reflow force, aucune forme alternative ne satisfait Sonar, CODE_QUALITY.md
|
||||
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.
|
||||
@@ -489,42 +499,27 @@ 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) {
|
||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
const template = widget?.dataset.quizTemplate || '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');
|
||||
}
|
||||
// Surligne la bonne réponse en vert (et le choix cliqué en rouge s'il
|
||||
// était faux) sur les boutons injectés par forgeShowQuizBox — extrait de
|
||||
// forgeQuizBoxAnswer (lot 4 "modernisation JS", S3776).
|
||||
function markQuizChoiceButtons(widget, state, choiceIndex) {
|
||||
if (!widget) return;
|
||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (const [i, button] of buttons.entries()) {
|
||||
button.disabled = true;
|
||||
if (i === state.correctIndex) button.classList.add('is-correct');
|
||||
else if (i === choiceIndex) button.classList.add('is-wrong');
|
||||
}
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
}
|
||||
|
||||
// Crédite le score/compteurs SI la réponse est bonne, puis déclenche
|
||||
// l'effet visuel du modèle actif (onCorrect/onWrong, voir static/js/play/
|
||||
// quiz-box-templates.js) — extrait de forgeQuizBoxAnswer.
|
||||
function applyQuizAnswerScoreAndEffect(widget, state, correct, fx) {
|
||||
if (correct) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
state.correctCount += 1;
|
||||
@@ -533,6 +528,11 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
} else if (fx.onWrong) {
|
||||
fx.onWrong(widget);
|
||||
}
|
||||
}
|
||||
|
||||
// xAPI + SCORM 2004 (cmi.interactions.n.*, no-op en SCORM 1.2/hors LMS) —
|
||||
// extrait de forgeQuizBoxAnswer.
|
||||
function notifyQuizAnswerTelemetry(state, correct, choiceIndex) {
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||
forgeXapiNotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
@@ -543,8 +543,6 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
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,
|
||||
@@ -556,19 +554,50 @@ function forgeQuizBoxAnswer(quizObjectId, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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, 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). Extrait de
|
||||
// forgeQuizBoxAnswer.
|
||||
function enableQuizContinueButton(widget) {
|
||||
if (!widget) return;
|
||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
|
||||
// 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). 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?.dataset.timerMode === 'question') forgeStopQuizTimer(quizObjectId);
|
||||
markQuizChoiceButtons(widget, state, choiceIndex);
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
applyQuizAnswerScoreAndEffect(widget, state, correct, fx);
|
||||
notifyQuizAnswerTelemetry(state, correct, choiceIndex);
|
||||
enableQuizContinueButton(widget);
|
||||
}
|
||||
|
||||
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
|
||||
@@ -595,7 +624,7 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
const { answeredCount } = state;
|
||||
return function () {
|
||||
widget.classList.add('is-active');
|
||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
||||
if (widget.dataset.fullscreen === '1') widget.classList.add('is-fullscreen');
|
||||
completeFx(widget, { correctCount, totalCount: answeredCount });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,3 +111,25 @@ test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', (
|
||||
assert.equal(decoded.clauses.length, 1);
|
||||
assert.equal(decoded.combinator, 'ou');
|
||||
});
|
||||
|
||||
test('forgeDecodeClauses — _filtres_json invalide : avertit en console, retombe sur le legacy sans planter (bug S2486 : exception avalée en silence auparavant)', () => {
|
||||
const attrs = {
|
||||
_filtres_json: '{not valid json',
|
||||
_filtre_champ: 'pv',
|
||||
_filtre_operateur: 'superieur_egal',
|
||||
_filtre_valeur: '10',
|
||||
};
|
||||
const originalWarn = console.warn;
|
||||
const warnCalls = [];
|
||||
console.warn = (...args) => { warnCalls.push(args); };
|
||||
try {
|
||||
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
|
||||
assert.equal(warnCalls.length, 1);
|
||||
assert.match(warnCalls[0][0], /_filtres_json invalide/);
|
||||
// Repli sur l'ancien format à 2 filtres fixes, comme avant ce fix.
|
||||
assert.equal(decoded.clauses.length, 1);
|
||||
assert.equal(decoded.clauses[0].champ, 'pv');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,6 +136,121 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
|
||||
assert.ok(html.includes('jaugeBar'));
|
||||
});
|
||||
|
||||
// Couverture des branches d'afficherValeur/fieldNameNom AVANT le refactor
|
||||
// S3776 (24 -> lot 4 "modernisation JS") — pour garantir que l'extraction
|
||||
// en sous-fonctions ne change aucun comportement.
|
||||
test('jauge — _afficher_valeur affiche la valeur brute du champ dans un label superposé', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _afficher_valeur: '1',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('>42</div>'));
|
||||
});
|
||||
|
||||
test('jauge — _afficher_valeur sans valeur trouvée (champ inconnu) affiche un tiret cadratin, pas "null"/"undefined"', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'champ_qui_n_existe_pas', _min: '0', _max: '100', _afficher_valeur: '1',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('>—</div>'));
|
||||
});
|
||||
|
||||
test('jauge — _row_id sélectionne une ligne précise (pas la plus récente)', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _row_id: '2', _min: '0', _max: '100',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="7"')); // ligne id=2 (Bob, points=7), pas la plus récente (Alice)
|
||||
});
|
||||
|
||||
test('jauge — _champ_nom affiche un label avec le nom, aligné à gauche seulement en position "dessus"+"gauche"', () => {
|
||||
const elGauche = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1',
|
||||
_champ: 'points',
|
||||
_champ_nom: 'nom',
|
||||
_min: '0',
|
||||
_max: '100',
|
||||
_nom_position: 'dessus',
|
||||
_nom_alignement: 'gauche',
|
||||
_nom_gras: '1',
|
||||
_nom_italique: '1',
|
||||
},
|
||||
};
|
||||
const htmlGauche = forgeRenderElementHtml(elGauche, baseGameData(), {}, {});
|
||||
assert.ok(htmlGauche.includes('text-align:left'));
|
||||
assert.ok(htmlGauche.includes('font-weight:700'));
|
||||
assert.ok(htmlGauche.includes('font-style:italic'));
|
||||
assert.ok(htmlGauche.includes('>Alice</div>'));
|
||||
|
||||
const elCote = {
|
||||
id: 11,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _champ_nom: 'nom', _min: '0', _max: '100', _nom_position: 'cote',
|
||||
},
|
||||
};
|
||||
const htmlCote = forgeRenderElementHtml(elCote, baseGameData(), {}, {});
|
||||
assert.ok(htmlCote.includes('text-align:center'));
|
||||
assert.ok(htmlCote.includes('flex-direction:row')); // "cote" bascule aussi la disposition générale
|
||||
});
|
||||
|
||||
test('jauge — _champ_nom sans valeur trouvée (ligne/champ vide) : aucun label de nom généré', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _champ_nom: 'champ_qui_n_existe_pas', _min: '0', _max: '100',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(!html.includes('text-align'));
|
||||
});
|
||||
|
||||
test('jauge — classe CSS supplémentaire (_class_*) fusionnée sur la barre', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _class_extra: 'ma-classe',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.match(html, /class="progress jaugeBar ma-classe"/);
|
||||
});
|
||||
|
||||
test('jauge — min/max absents retombent sur 0/100, valeur non numérique retombe sur min', () => {
|
||||
const el = {
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: { _definition_id: '1', _champ: 'nom' }, // "nom" = texte, pas un nombre
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="0"')); // valeur non numérique -> repli sur min (0 par défaut)
|
||||
});
|
||||
|
||||
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
const el = {
|
||||
id: 11,
|
||||
|
||||
@@ -18,53 +18,82 @@ function forgePyMod(a, b) {
|
||||
return ((a % b) + b) % b;
|
||||
}
|
||||
|
||||
function computeDefinirNombre(rawValue, round) {
|
||||
const n = Number.parseFloat(rawValue);
|
||||
return Number.isNaN(n) ? 0 : round(n);
|
||||
}
|
||||
|
||||
// Partagée entre "incrementer" et "decrementer" (même calcul, signe
|
||||
// inversé) — extrait de forgeComputeNewValue (lot 4 "modernisation JS", S3776).
|
||||
function computeIncrementerOuDecrementer(operation, current, rawValue, round) {
|
||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(amount)) amount = 0;
|
||||
if (operation === 'decrementer') amount = -amount;
|
||||
return round((current || 0) + amount);
|
||||
}
|
||||
|
||||
function computeMultiplier(current, rawValue, round) {
|
||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(factor)) factor = 1;
|
||||
return round((current || 0) * factor);
|
||||
}
|
||||
|
||||
// Partagée entre "diviser" et "modulo" — extrait de forgeComputeNewValue.
|
||||
function computeDiviserOuModulo(operation, current, rawValue, round) {
|
||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(divisor)) divisor = 0;
|
||||
if (divisor === 0) return current;
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
|
||||
// Partagée entre "minimum" et "maximum" — extrait de forgeComputeNewValue.
|
||||
function computeMinimumOuMaximum(operation, current, rawValue, round) {
|
||||
const bound = Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(bound)) return current;
|
||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
return round(res);
|
||||
}
|
||||
|
||||
function computeAlea(rawValue, isDecimal) {
|
||||
const parts = String(rawValue || '').split(',');
|
||||
let lo = Number.parseFloat(parts[0]); let
|
||||
hi = Number.parseFloat(parts[1]);
|
||||
if (Number.isNaN(lo)) lo = 0;
|
||||
if (Number.isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
const iLo = Math.trunc(lo); const
|
||||
iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
}
|
||||
|
||||
// Dispatch par nom d'opération — chaque fonction ci-dessus reçoit déjà
|
||||
// tout ce dont elle a besoin (current/rawValue/round/isDecimal), ce
|
||||
// dispatcher n'a donc plus qu'une seule décision (présence d'un
|
||||
// handler), au lieu d'empiler ici les conditions de chaque branche (même
|
||||
// méthode que runActionNode, static/js/play/actions.js).
|
||||
const OPERATION_HANDLERS = {
|
||||
definir_texte: (current, rawValue) => rawValue || '',
|
||||
definir_nombre: (current, rawValue, round) => computeDefinirNombre(rawValue, round),
|
||||
definir_bool_vrai: () => 1,
|
||||
definir_bool_faux: () => 0,
|
||||
basculer_bool: (current) => (current ? 0 : 1),
|
||||
incrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('incrementer', current, rawValue, round),
|
||||
decrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('decrementer', current, rawValue, round),
|
||||
multiplier: (current, rawValue, round) => computeMultiplier(current, rawValue, round),
|
||||
diviser: (current, rawValue, round) => computeDiviserOuModulo('diviser', current, rawValue, round),
|
||||
modulo: (current, rawValue, round) => computeDiviserOuModulo('modulo', current, rawValue, round),
|
||||
minimum: (current, rawValue, round) => computeMinimumOuMaximum('minimum', current, rawValue, round),
|
||||
maximum: (current, rawValue, round) => computeMinimumOuMaximum('maximum', current, rawValue, round),
|
||||
alea: (current, rawValue, round, isDecimal) => computeAlea(rawValue, isDecimal),
|
||||
};
|
||||
|
||||
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === 'definir_texte') return rawValue || '';
|
||||
if (operation === 'definir_nombre') {
|
||||
const n = parseFloat(rawValue);
|
||||
return Number.isNaN(n) ? 0 : round(n);
|
||||
}
|
||||
if (operation === 'definir_bool_vrai') return 1;
|
||||
if (operation === 'definir_bool_faux') return 0;
|
||||
if (operation === 'basculer_bool') return current ? 0 : 1;
|
||||
if (operation === 'incrementer' || operation === 'decrementer') {
|
||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(amount)) amount = 0;
|
||||
if (operation === 'decrementer') amount = -amount;
|
||||
return round((current || 0) + amount);
|
||||
}
|
||||
if (operation === 'multiplier') {
|
||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : parseFloat(rawValue);
|
||||
if (Number.isNaN(factor)) factor = 1;
|
||||
return round((current || 0) * factor);
|
||||
}
|
||||
if (operation === 'diviser' || operation === 'modulo') {
|
||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(divisor)) divisor = 0;
|
||||
if (divisor === 0) return current;
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
if (operation === 'minimum' || operation === 'maximum') {
|
||||
const bound = parseFloat(rawValue);
|
||||
if (Number.isNaN(bound)) return current;
|
||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
return round(res);
|
||||
}
|
||||
if (operation === 'alea') {
|
||||
const parts = String(rawValue || '').split(',');
|
||||
let lo = parseFloat(parts[0]); let
|
||||
hi = parseFloat(parts[1]);
|
||||
if (Number.isNaN(lo)) lo = 0;
|
||||
if (Number.isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
const iLo = Math.trunc(lo); const
|
||||
iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
}
|
||||
throw new Error(`opération inconnue : ${operation}`);
|
||||
const handler = OPERATION_HANDLERS[operation];
|
||||
if (!handler) throw new Error(`opération inconnue : ${operation}`);
|
||||
return handler(current, rawValue, round, isDecimal);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
|
||||
@@ -21,7 +21,15 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) clauses = parsed;
|
||||
} catch (e) { clauses = null; }
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// _filtres_json est produit par le rendu serveur, jamais tapé à la
|
||||
// main — un JSON invalide ici trahit presque toujours un bug côté
|
||||
// éditeur/serveur. Comportement inchangé : repli sur l'ancien
|
||||
// format ou aucun filtre, comme avant.
|
||||
console.warn('_filtres_json invalide, filtre ignoré', e);
|
||||
clauses = null;
|
||||
}
|
||||
}
|
||||
if (clauses === null && legacyPrefix) {
|
||||
clauses = [];
|
||||
@@ -48,7 +56,7 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
let current;
|
||||
try {
|
||||
current = rawValue ? JSON.parse(rawValue) : null;
|
||||
} catch (e) {
|
||||
} catch (e) { // NOSONAR S2486 - variable pas au format JSON, cas normal (voir commentaire de la fonction)
|
||||
return null;
|
||||
}
|
||||
let m;
|
||||
@@ -57,9 +65,9 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
const key = m[1]; const
|
||||
index = m[2];
|
||||
try {
|
||||
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
|
||||
current = key !== undefined ? current[key] : current[Number.parseInt(index, 10)];
|
||||
if (current === undefined) return null;
|
||||
} catch (e) {
|
||||
} catch (e) { // NOSONAR S2486 - chemin qui ne correspond pas a la forme actuelle de la variable, cas normal
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +99,7 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
|
||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
if (varMatch) {
|
||||
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
const variable = gameData.variables?.[varMatch[1].trim()];
|
||||
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
||||
}
|
||||
@@ -103,8 +111,8 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
const names = gameData.definition_names || {};
|
||||
for (const defId in names) {
|
||||
if (names[defId] === objectName) {
|
||||
const rows = gameData.data && gameData.data[defId];
|
||||
if (rows && rows.length) {
|
||||
const rows = gameData.data?.[defId];
|
||||
if (rows?.length) {
|
||||
return rows[0][fieldName];
|
||||
}
|
||||
return null;
|
||||
@@ -119,7 +127,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
if (!fieldName) return true;
|
||||
const operator = clause.operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
@@ -127,7 +135,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
|
||||
// Port de filter_rows_by_clauses.
|
||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||
if (!clauses || !clauses.length) return rows;
|
||||
if (!clauses?.length) return rows;
|
||||
if (combinator === 'ou') {
|
||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||
// via forgeSpecialRenderer().
|
||||
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = new Set(['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau']);
|
||||
|
||||
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
||||
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
||||
@@ -66,9 +66,9 @@ const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
|
||||
|
||||
function forgeHtmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
||||
@@ -89,7 +89,7 @@ function forgeAutoId(el) {
|
||||
|
||||
// Port de parent_flex_direction.py.
|
||||
function forgeParentFlexDirection(parentEl) {
|
||||
const style = (parentEl && parentEl.style) || {};
|
||||
const style = parentEl?.style || {};
|
||||
if (style.display !== 'flex') return null;
|
||||
return style['flex-direction'] || 'row';
|
||||
}
|
||||
@@ -99,8 +99,8 @@ function forgeParentFlexDirection(parentEl) {
|
||||
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
|
||||
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
||||
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
const style = { ...el.style || {} };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
||||
const style = { ...el.style };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.has(widgetKey) && !('display' in style)) {
|
||||
style.display = 'flex';
|
||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||
}
|
||||
@@ -150,13 +150,13 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
||||
const rawAttrs = el.attributes || {};
|
||||
const attrs = {};
|
||||
Object.keys(rawAttrs).forEach((k) => {
|
||||
if (k.indexOf('_') === 0) return;
|
||||
if (k.startsWith('_')) return;
|
||||
const v = rawAttrs[k];
|
||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||
});
|
||||
Object.assign(attrs, meta.fixedAttrs || {});
|
||||
const extraClasses = Object.keys(rawAttrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
||||
.filter((k) => k.startsWith('_class_') && rawAttrs[k])
|
||||
.map((k) => rawAttrs[k]);
|
||||
if (extraClasses.length) {
|
||||
const baseClass = attrs.class || '';
|
||||
@@ -170,7 +170,7 @@ function forgeVisibleAttrs(el, meta, ctx) {
|
||||
|
||||
// Port de render_children.py.
|
||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
const kids = childrenMap?.[el.id] || [];
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
||||
}
|
||||
@@ -182,9 +182,9 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
let definitionId = attrs._data_definition_id;
|
||||
if (!definitionId) return null;
|
||||
definitionId = String(definitionId);
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||
if (!fieldsMetaForDef) return null;
|
||||
let rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
let rows = gameData.data?.[definitionId] || [];
|
||||
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
||||
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
if (!rows.length) return null;
|
||||
@@ -206,8 +206,8 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
||||
// d'éventuels enfants copiés en base.
|
||||
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
||||
const templateTree = gameData.element_type_templates?.[String(el.element_type_id)];
|
||||
if (!templateTree?.top.length) return [null, false];
|
||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||
let { top } = templateTree;
|
||||
if (isOverlayOnly) {
|
||||
@@ -255,7 +255,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
const attrsRaw = el.attributes || {};
|
||||
|
||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
||||
const playMode = !!((ctx || {})._forge_play_mode);
|
||||
const playMode = !!ctx?._forge_play_mode;
|
||||
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
||||
}
|
||||
@@ -268,7 +268,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
};
|
||||
|
||||
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
|
||||
if (boundCtx) ctx = { ...ctx, ...boundCtx };
|
||||
|
||||
// meta d'un widget à special_render : fixedAttrs propre (voir
|
||||
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
||||
@@ -319,7 +319,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
||||
}
|
||||
|
||||
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
|
||||
const safeContent = content ? forgeHtmlEscape(content).replaceAll('\n', '<br>') : '';
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,25 @@
|
||||
function forgeHexToRgb(hexColor) {
|
||||
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
||||
if (hex.length !== 6) hex = '5b8cff';
|
||||
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
|
||||
return [
|
||||
Number.parseInt(hex.substr(0, 2), 16),
|
||||
Number.parseInt(hex.substr(2, 2), 16),
|
||||
Number.parseInt(hex.substr(4, 2), 16),
|
||||
];
|
||||
}
|
||||
|
||||
// Extrait de forgeInterpolateColor (lot 4 "modernisation JS", S7721) :
|
||||
// fonction pure (aucune fermeture sur lo/hi/mixed/ratio), pas besoin
|
||||
// d'être redéfinie à chaque appel.
|
||||
function forgeByteToHex(n) {
|
||||
return n.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
const lo = forgeHexToRgb(lowHex); const
|
||||
hi = forgeHexToRgb(highHex);
|
||||
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
||||
const toHex = function (n) { return n.toString(16).padStart(2, '0'); };
|
||||
return `#${mixed.map(toHex).join('')}`;
|
||||
return `#${mixed.map(forgeByteToHex).join('')}`;
|
||||
}
|
||||
|
||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
||||
@@ -23,14 +33,42 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||
if (!definitionId || !fieldName) return null;
|
||||
definitionId = String(definitionId);
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return null;
|
||||
const rows = gameData.data?.[definitionId];
|
||||
if (!rows?.length) return null;
|
||||
let row = null;
|
||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||
if (!row) row = rows[0];
|
||||
return row[fieldName];
|
||||
}
|
||||
|
||||
// Label superposé qui affiche la valeur BRUTE du champ (indépendant du
|
||||
// pourcentage/couleur) — extrait de forgeRenderJauge (lot 4 "modernisation
|
||||
// JS", S3776).
|
||||
function forgeJaugeValueLabelHtml(afficherValeur, rawValue) {
|
||||
if (!afficherValeur) return '';
|
||||
return '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||
}
|
||||
|
||||
// Label du NOM (ex. "Alice"), lu depuis un second champ optionnel — extrait
|
||||
// de forgeRenderJauge.
|
||||
function forgeJaugeNameLabelHtml(gameData, definitionId, rowId, fieldNameNom, nomPosition, nomAlignement, nomStyle) {
|
||||
if (!fieldNameNom) return '';
|
||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue === null || nomValue === undefined || nomValue === '') return '';
|
||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||
const {
|
||||
nomPolice, nomTaille, nomGras, nomItalique,
|
||||
} = nomStyle;
|
||||
return `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||
}
|
||||
|
||||
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const attrs = el.attributes || {};
|
||||
const nomPosition = attrs._nom_position || 'dessus';
|
||||
@@ -47,14 +85,14 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
const rowId = attrs._row_id;
|
||||
const fieldName = (attrs._champ || '').trim();
|
||||
const fieldNameNom = (attrs._champ_nom || '').trim();
|
||||
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||
let minV = Number.parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||
let maxV = Number.parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||
const afficherValeur = attrs._afficher_valeur === '1';
|
||||
|
||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
let value = parseFloat(rawValue);
|
||||
let value = Number.parseFloat(rawValue);
|
||||
if (Number.isNaN(value)) value = minV;
|
||||
|
||||
const span = (maxV - minV) || 1;
|
||||
@@ -62,29 +100,22 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
|
||||
let labelHtml = '';
|
||||
if (afficherValeur) {
|
||||
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||
}
|
||||
|
||||
let nameHtml = '';
|
||||
if (fieldNameNom) {
|
||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
|
||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||
nameHtml = `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||
}
|
||||
}
|
||||
const labelHtml = forgeJaugeValueLabelHtml(afficherValeur, rawValue);
|
||||
const nomStyle = {
|
||||
nomPolice, nomTaille, nomGras, nomItalique,
|
||||
};
|
||||
const nameHtml = forgeJaugeNameLabelHtml(
|
||||
gameData,
|
||||
definitionId,
|
||||
rowId,
|
||||
fieldNameNom,
|
||||
nomPosition,
|
||||
nomAlignement,
|
||||
nomStyle,
|
||||
);
|
||||
|
||||
const extraClasses = Object.keys(attrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
|
||||
.filter((k) => k.startsWith('_class_') && attrs[k])
|
||||
.map((k) => attrs[k]).join(' ');
|
||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
||||
// mais le port reste fidèle pour ne pas diverger inutilement).
|
||||
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
const isPlayMode = !!ctx?._forge_play_mode;
|
||||
const attrsRaw = el.attributes || {};
|
||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||
const radius = attrsRaw._arrondi || '12';
|
||||
|
||||
@@ -10,14 +10,14 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
||||
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
||||
const templateTypeId = attrsRaw._template_type_id;
|
||||
const template = forgeApplyCtx(el.content || '', ctx);
|
||||
const children = (childrenMap && childrenMap[el.id]) || [];
|
||||
const children = childrenMap?.[el.id] || [];
|
||||
|
||||
if (!definitionId) {
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
||||
}
|
||||
definitionId = String(definitionId);
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
|
||||
if (!fieldsMetaForDef) {
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
||||
@@ -28,11 +28,11 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
||||
|
||||
let templateTree = null;
|
||||
if (templateTypeId) {
|
||||
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
|
||||
templateTree = gameData.element_type_templates?.[String(templateTypeId)];
|
||||
}
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const rowCtx = { ...ctx || {} };
|
||||
const rowCtx = { ...ctx };
|
||||
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
||||
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
||||
// donc directement la bonne valeur, y compris pour un champ "relation"
|
||||
|
||||
@@ -48,7 +48,7 @@ function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDir
|
||||
|
||||
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
const headerRow = ['1', 'true', 'on'].includes(el.attributes?._header_row);
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const rows = content.split('\n').filter((r) => r.trim());
|
||||
@@ -83,7 +83,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// 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.
|
||||
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
|
||||
const iconUrl = gameData.icon_data_uris?.[iconSlug] || (`icons/${iconSlug}.svg`);
|
||||
const 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
|
||||
@@ -94,7 +94,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
}
|
||||
|
||||
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
const tabs = childrenMap?.[el.id] || [];
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const tabsExtraClasses = attrs.class || '';
|
||||
delete attrs.class;
|
||||
@@ -138,9 +138,9 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
|
||||
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs['data-personnage'] = '1';
|
||||
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
const animations = gameData.personnage_animations?.[String(el.id)];
|
||||
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
const idleFrame = frames && frames.length ? frames[0] : '';
|
||||
const idleFrame = 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.
|
||||
|
||||
@@ -21,8 +21,8 @@ let activePersonnageInteractListener = null;
|
||||
const FORGE_PERSONNAGE_FPS = 8;
|
||||
|
||||
function forgePersonnageAnimExists(elementId, animName) {
|
||||
const byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
||||
const byAnim = gameData.personnage_animations?.[elementId];
|
||||
return !!byAnim?.[animName]?.length;
|
||||
}
|
||||
|
||||
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
|
||||
@@ -81,10 +81,10 @@ function forgeUpdateSceneCamera(screenData) {
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||
const px = playerEl
|
||||
? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2
|
||||
? (Number.parseFloat(playerEl.style.left) || 0) + (Number.parseFloat(playerEl.style.width) || 0) / 2
|
||||
: world.width / 2;
|
||||
const py = playerEl
|
||||
? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2
|
||||
? (Number.parseFloat(playerEl.style.top) || 0) + (Number.parseFloat(playerEl.style.height) || 0) / 2
|
||||
: world.height / 2;
|
||||
const offsetX = Math.min(
|
||||
Math.max(px - screenData.scene_width / 2, 0),
|
||||
@@ -125,7 +125,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
||||
// objet dont la collision est explicitement désactivée, ni l'objet qui
|
||||
// se déplace lui-même.
|
||||
function forgeSolidObstacles(screenData, movingId) {
|
||||
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false));
|
||||
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && o.collision?.enabled !== false);
|
||||
}
|
||||
|
||||
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
|
||||
@@ -139,90 +139,118 @@ function forgeSolidObstacles(screenData, movingId) {
|
||||
function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) {
|
||||
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
|
||||
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
||||
for (let i = 0; i < obstacles.length; i++) {
|
||||
const other = obstacles[i];
|
||||
for (const other of obstacles) {
|
||||
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
||||
if (!otherEl) continue;
|
||||
const otherLeft = parseFloat(otherEl.style.left) || 0;
|
||||
const otherTop = parseFloat(otherEl.style.top) || 0;
|
||||
const otherWidth = parseFloat(otherEl.style.width) || 0;
|
||||
const otherHeight = parseFloat(otherEl.style.height) || 0;
|
||||
const otherLeft = Number.parseFloat(otherEl.style.left) || 0;
|
||||
const otherTop = Number.parseFloat(otherEl.style.top) || 0;
|
||||
const otherWidth = Number.parseFloat(otherEl.style.width) || 0;
|
||||
const otherHeight = Number.parseFloat(otherEl.style.height) || 0;
|
||||
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
||||
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Déplacement BRUT tenté (avant collision), selon l'axe autorisé par cmd.axe
|
||||
// — extrait de forgeRunPersonnageControllerTick (lot 4 "modernisation JS", S3776).
|
||||
function forgeComputeMovementDelta(cmd) {
|
||||
let rawDx = 0; let
|
||||
rawDy = 0;
|
||||
if (cmd.axe !== 'vertical') {
|
||||
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||
}
|
||||
if (cmd.axe !== 'horizontal') {
|
||||
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
|
||||
}
|
||||
return { rawDx, rawDy };
|
||||
}
|
||||
|
||||
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un seul
|
||||
// est gêné) — glisser le long d'un mur en diagonale reste possible, plus
|
||||
// naturel qu'un blocage total au moindre contact. Extrait de
|
||||
// forgeRunPersonnageControllerTick.
|
||||
// `box` = {left, top, width, height} (position/taille ACTUELLES, avant
|
||||
// déplacement) ; `rawDelta` = {rawDx, rawDy} (voir forgeComputeMovementDelta)
|
||||
// — regroupés en objets pour rester sous la limite de 7 paramètres (S107),
|
||||
// plutôt que 9 paramètres positionnels distincts.
|
||||
function forgeResolveBlockedMovement(screenData, obj, targetEl, box, rawDelta) {
|
||||
const {
|
||||
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
|
||||
} = box;
|
||||
let { rawDx: dx, rawDy: dy } = rawDelta;
|
||||
if (dx !== 0) {
|
||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
||||
}
|
||||
return { dx, dy };
|
||||
}
|
||||
|
||||
// Anime idle (immobile) ou walk (déplacement effectif) — même fps pour les
|
||||
// deux (bug corrigé par l'utilisateur : idle tournait à 4 i/s contre 8
|
||||
// pour walk, aucune raison réelle de les désynchroniser). Extrait de
|
||||
// forgeRunPersonnageControllerTick.
|
||||
function forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy) {
|
||||
if (dx === 0 && dy === 0) {
|
||||
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Déplace/anime UN personnage joueur pour ce tick — extrait du forEach de
|
||||
// forgeRunPersonnageControllerTick.
|
||||
function forgeRunPersonnagePlayerTick(screenData, obj) {
|
||||
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
||||
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
||||
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
const cmd = obj.personnage_commandes;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
|
||||
const { rawDx, rawDy } = forgeComputeMovementDelta(cmd);
|
||||
const curLeft = Number.parseFloat(targetEl.style.left) || 0;
|
||||
const curTop = Number.parseFloat(targetEl.style.top) || 0;
|
||||
const boxWidth = Number.parseFloat(targetEl.style.width) || 0;
|
||||
const boxHeight = Number.parseFloat(targetEl.style.height) || 0;
|
||||
const box = {
|
||||
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
|
||||
};
|
||||
const { dx, dy } = forgeResolveBlockedMovement(screenData, obj, targetEl, box, { rawDx, rawDy });
|
||||
|
||||
// Se tourne vers la direction TENTÉE même si le déplacement est
|
||||
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
|
||||
// personnage qui reste dos tourné contre ce qui le bloque).
|
||||
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
|
||||
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy);
|
||||
}
|
||||
|
||||
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||
// (triggers.js), pour un déplacement perçu comme continu.
|
||||
function forgeRunPersonnageControllerTick(screenData) {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
||||
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
||||
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
const cmd = obj.personnage_commandes;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
|
||||
let rawDx = 0; let
|
||||
rawDy = 0;
|
||||
if (cmd.axe !== 'vertical') {
|
||||
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||
}
|
||||
if (cmd.axe !== 'horizontal') {
|
||||
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
|
||||
}
|
||||
|
||||
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un
|
||||
// seul est gêné) — glisser le long d'un mur en diagonale reste
|
||||
// possible, plus naturel qu'un blocage total au moindre contact.
|
||||
const curLeft = parseFloat(targetEl.style.left) || 0;
|
||||
const curTop = parseFloat(targetEl.style.top) || 0;
|
||||
const boxWidth = parseFloat(targetEl.style.width) || 0;
|
||||
const boxHeight = parseFloat(targetEl.style.height) || 0;
|
||||
let dx = rawDx; let
|
||||
dy = rawDy;
|
||||
if (dx !== 0) {
|
||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
||||
}
|
||||
// Se tourne vers la direction TENTÉE même si le déplacement est
|
||||
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
|
||||
// personnage qui reste dos tourné contre ce qui le bloque).
|
||||
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
|
||||
|
||||
// Même fps pour idle et walk (bug signalé par l'utilisateur : idle
|
||||
// tournait à 4 i/s contre 8 pour walk, perçue comme "les autres
|
||||
// animations sont lentes à côté de la marche" — aucune raison réelle
|
||||
// de les désynchroniser).
|
||||
if (dx === 0 && dy === 0) {
|
||||
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
(screenData.elements || []).forEach((obj) => forgeRunPersonnagePlayerTick(screenData, obj));
|
||||
}
|
||||
|
||||
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
|
||||
@@ -261,7 +289,7 @@ function forgeStartPersonnageInteractListener(screenData) {
|
||||
// réinitialisé à chaque changement d'écran (voir showScreen()).
|
||||
function forgeStartPersonnageControllers(screenData) {
|
||||
forgeStopPersonnageControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
if (screenData?.kind !== 'jeu_2d') return;
|
||||
const elements = screenData.elements || [];
|
||||
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasFond = elements.some((o) => o.kind === 'fond');
|
||||
|
||||
@@ -15,27 +15,38 @@
|
||||
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
||||
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
||||
// classique (pas de module), lisibles ici telles quelles.
|
||||
// Extrait de forgeQuizProgressOnShow (lot 4 "modernisation JS", S7721) :
|
||||
// fonction pure (aucune fermeture), pas besoin d'être redéfinie à chaque appel.
|
||||
function isQuestionLine(line) { return line?.type === 'question'; }
|
||||
|
||||
// Extrait de forgeQuizProgressOnShow (S3358) : segment "fait"/"courant"/
|
||||
// "à venir" de la barre d'étapes pour la question d'indice `i` (1-based).
|
||||
function forgeQuizProgressBarSegmentClass(i, current) {
|
||||
if (i < current) return 'is-done';
|
||||
if (i === current) return 'is-current';
|
||||
return '';
|
||||
}
|
||||
|
||||
// `progressSelector` : chaque modèle a son propre conteneur de barre
|
||||
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||
if (!widget) return;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
if (!quizState) return;
|
||||
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
if (!dialogueState) return;
|
||||
function isQuestion(line) { return line && line.type === 'question'; }
|
||||
const total = dialogueState.lines.filter(isQuestion).length;
|
||||
const total = dialogueState.lines.filter(isQuestionLine).length;
|
||||
if (total === 0) return;
|
||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestionLine).length;
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = `Question ${current}/${total}`;
|
||||
const progressEl = widget.querySelector(progressSelector);
|
||||
if (progressEl) {
|
||||
const bars = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
bars.push(`<i class="${i < current ? 'is-done' : (i === current ? 'is-current' : '')}"></i>`);
|
||||
bars.push(`<i class="${forgeQuizProgressBarSegmentClass(i, current)}"></i>`);
|
||||
}
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
@@ -79,15 +90,15 @@ function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
const zone = widget.querySelector('[data-classique-role="result"]');
|
||||
if (!content || !zone) return;
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && result.correctCount) || 0;
|
||||
const total = result?.totalCount || 0;
|
||||
const correct = result?.correctCount || 0;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const passed = pct >= threshold;
|
||||
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
const title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
zone.innerHTML = `<div class="classiqueResultRing" style="--pct:${pct}"><span>${pct}%</span></div>`
|
||||
+ `<p class="classiqueResultTitle">${correct} bonne${correct > 1 ? 's' : ''} réponse${correct > 1 ? 's' : ''} sur ${total}</p>`
|
||||
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
||||
@@ -112,13 +123,13 @@ function forgeQuizMangaOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
const zone = widget.querySelector('[data-manga-role="result"]');
|
||||
if (!zone) return;
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && result.correctCount) || 0;
|
||||
const total = result?.totalCount || 0;
|
||||
const correct = result?.correctCount || 0;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const passed = pct >= threshold;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizObjectId = widget.dataset.objectId;
|
||||
zone.innerHTML = '<div class="mangaResultCard">'
|
||||
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
||||
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
const rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
if (!rules?.length) return;
|
||||
rules.forEach((rule) => {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
|
||||
@@ -82,7 +82,7 @@ function runScreenBackgroundMusic(screenId) {
|
||||
currentBackgroundAudio = null;
|
||||
}
|
||||
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
||||
const url = screenData && screenData.background_music_url;
|
||||
const url = screenData?.background_music_url;
|
||||
if (!url) return;
|
||||
currentBackgroundAudio = new Audio(url);
|
||||
currentBackgroundAudio.loop = true;
|
||||
@@ -94,8 +94,8 @@ function runScreenBackgroundMusic(screenId) {
|
||||
// que soit la forme réelle de la fenêtre du joueur.
|
||||
function applyScreenAspect(aspect) {
|
||||
const parts = String(aspect).split('/');
|
||||
const w = parseFloat(parts[0]) || 9; const
|
||||
h = parseFloat(parts[1]) || 16;
|
||||
const w = Number.parseFloat(parts[0]) || 9; const
|
||||
h = Number.parseFloat(parts[1]) || 16;
|
||||
const frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.style.setProperty('--ratio-w', w);
|
||||
@@ -231,12 +231,12 @@ function collectAnimationClips(screenId) {
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
clips = clips.concat(gameData.animations?.[String(sid)] || []);
|
||||
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach((el) => {
|
||||
if (!el.element_type_id) return;
|
||||
const templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
const templateScreenId = gameData.element_types?.[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function bindHoverTexts() {
|
||||
// Espace déclenchent le même comportement que le clic.
|
||||
function bindClicks() {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const elementId = Number.parseInt(elDiv.dataset.elementId, 10);
|
||||
const triggers = findTriggerNodess(elementId, 'clic');
|
||||
if (triggers.length) {
|
||||
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
||||
@@ -133,8 +133,9 @@ function bindClicks() {
|
||||
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
|
||||
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
||||
const rowEl = ev.target.closest('.repeaterItem');
|
||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
window.lastClickedRowId = rowEl ? Number.parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId
|
||||
? Number.parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
@@ -165,7 +166,7 @@ function bindClicks() {
|
||||
function bindHoverTriggers() {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
if (elDiv.dataset.hoverTriggerBound) return;
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const elementId = Number.parseInt(elDiv.dataset.elementId, 10);
|
||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
||||
if (!enterTriggers.length && !leaveTriggers.length) return;
|
||||
|
||||
Reference in New Issue
Block a user