Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -79,7 +79,7 @@ test('runSpriteAnimation — une animation non bouclée en cours résiste à une
|
||||
});
|
||||
|
||||
test('resolveSpriteFrames — résout depuis gameData.personnage_animations par nom', () => {
|
||||
global.gameData = { personnage_animations: { '5': { walk: ['/w0.png', '/w1.png'] } } };
|
||||
global.gameData = { personnage_animations: { 5: { walk: ['/w0.png', '/w1.png'] } } };
|
||||
const resolved = resolveSpriteFrames('5', { animation: 'walk', fps: 12, loop: true });
|
||||
assert.deepEqual(resolved.frames, ['/w0.png', '/w1.png']);
|
||||
assert.equal(resolved.fps, 12);
|
||||
@@ -101,7 +101,7 @@ 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' } };
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 500), 160); // 200 - 40
|
||||
assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40
|
||||
assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +118,9 @@ test('clampSceneObjectPosition — borne au bord gauche/haut (jamais négatif)',
|
||||
// grand que la scène doit laisser le personnage se déplacer sur TOUT le
|
||||
// fond, pas seulement le petit cadre visible de la scène d'origine.
|
||||
test('clampSceneObjectPosition — borne au MONDE (world_width/height) quand un fond XXL l\'a agrandi', () => {
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000 }, () => {
|
||||
withScreensData({
|
||||
id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000,
|
||||
}, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 500), 500); // pas encore au bord du MONDE
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 5000), 1960); // 2000 - 40
|
||||
@@ -134,7 +136,11 @@ test('clampSceneObjectPosition — sans world_width/height (aucun fond), retombe
|
||||
|
||||
test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène après plusieurs pas', () => {
|
||||
withScreensData({ id: 1, scene_width: 100, scene_height: 100 }, () => {
|
||||
const el = { style: { left: '90px', top: '0px', width: '20px', height: '20px' } };
|
||||
const el = {
|
||||
style: {
|
||||
left: '90px', top: '0px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
applyObjectProperty(el, 'pos_x_relatif', '5');
|
||||
applyObjectProperty(el, 'pos_x_relatif', '5');
|
||||
applyObjectProperty(el, 'pos_x_relatif', '5');
|
||||
|
||||
@@ -16,6 +16,7 @@ global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier
|
||||
@@ -28,12 +29,12 @@ function fakeEl(style) {
|
||||
const classes = new Set();
|
||||
const children = [];
|
||||
return {
|
||||
style: style,
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
style,
|
||||
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
|
||||
children,
|
||||
appendChild(child) { children.push(child); },
|
||||
querySelector(sel) {
|
||||
if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget);
|
||||
if (sel === '.dialogueBoxWidget') return children.find((c) => c._isDialogueBoxWidget);
|
||||
if (sel === '[data-dialogue-role="header"]') return this._header;
|
||||
if (sel === '[data-dialogue-role="body"]') return this._body;
|
||||
if (sel === '[data-dialogue-role="footer"]') return this._footer;
|
||||
@@ -46,19 +47,26 @@ function fakeEl(style) {
|
||||
test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const mrPinguin = {
|
||||
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
|
||||
id: 2,
|
||||
kind: 'personnage',
|
||||
name: 'Mr. Pinguin',
|
||||
collision_rules: [{
|
||||
trigger: 'collision',
|
||||
action: {
|
||||
type: 'dialogue', mark_completed: true,
|
||||
type: 'dialogue',
|
||||
mark_completed: true,
|
||||
dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const box = { id: 3, kind: 'dialogue_box' };
|
||||
|
||||
const playerEl = fakeEl({ left: '0px', top: '0px', width: '20px', height: '20px' });
|
||||
const pinguinEl = fakeEl({ left: '5px', top: '5px', width: '20px', height: '20px' }); // chevauche playerEl
|
||||
const playerEl = fakeEl({
|
||||
left: '0px', top: '0px', width: '20px', height: '20px',
|
||||
});
|
||||
const pinguinEl = fakeEl({
|
||||
left: '5px', top: '5px', width: '20px', height: '20px',
|
||||
}); // chevauche playerEl
|
||||
const boxWidget = fakeEl({});
|
||||
boxWidget._isDialogueBoxWidget = true;
|
||||
boxWidget._objectId = '3';
|
||||
@@ -107,6 +115,6 @@ test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_comple
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_intro, true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
@@ -34,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._forObjectId === m[1]) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -42,22 +43,28 @@ function fakeParentEl() {
|
||||
function fakeEl(parentEl) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
style: { left: '0px', top: '0px', width: '20px', height: '20px' },
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), toggle: c => {}, contains: c => classes.has(c) },
|
||||
style: {
|
||||
left: '0px', top: '0px', width: '20px', height: '20px',
|
||||
},
|
||||
classList: {
|
||||
add: (c) => classes.add(c), remove: (c) => classes.delete(c), toggle: (_c) => {}, contains: (c) => classes.has(c),
|
||||
},
|
||||
parentElement: parentEl,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBubbleEl() {
|
||||
return {
|
||||
className: 'collisionInteractBubble', style: {}, textContent: '',
|
||||
className: 'collisionInteractBubble',
|
||||
style: {},
|
||||
textContent: '',
|
||||
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDocument(elByObjectId) {
|
||||
return {
|
||||
querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
|
||||
querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
|
||||
createElement: () => fakeBubbleEl(),
|
||||
};
|
||||
}
|
||||
@@ -65,7 +72,9 @@ function fakeDocument(elByObjectId) {
|
||||
test.afterEach(() => { forgeStopCollisionRuleControllers(); });
|
||||
|
||||
function rect(left, top, width, height) {
|
||||
return { left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle' };
|
||||
return {
|
||||
left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle',
|
||||
};
|
||||
}
|
||||
|
||||
test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai si les boîtes se chevauchent, faux si loin', () => {
|
||||
@@ -93,7 +102,7 @@ test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueB
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }];
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]);
|
||||
});
|
||||
@@ -114,8 +123,12 @@ test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_v
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null,
|
||||
type: 'condition',
|
||||
variable: 'inventaire_cle',
|
||||
operateur: 'superieur',
|
||||
valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } },
|
||||
si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
@@ -129,7 +142,10 @@ test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
type: 'condition',
|
||||
variable: 'inventaire_cle',
|
||||
operateur: 'superieur',
|
||||
valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } },
|
||||
};
|
||||
@@ -144,8 +160,12 @@ test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien fair
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null,
|
||||
type: 'condition',
|
||||
variable: 'inventaire_cle',
|
||||
operateur: 'superieur',
|
||||
valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
@@ -154,15 +174,22 @@ test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien fair
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } };
|
||||
forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3',
|
||||
}, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '5');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } };
|
||||
const action = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' },
|
||||
type: 'variable',
|
||||
target_variable: 'score',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '1',
|
||||
then: {
|
||||
type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1',
|
||||
},
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
@@ -174,7 +201,9 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
const thenAction = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
};
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
@@ -183,7 +212,7 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" bascule .forgeHighlight sur l\'objet CIBLÉ (object_id), pas forcément l\'objet du déclencheur', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.includes('"9"') ? targetEl : undefined) };
|
||||
global.document = { querySelector: (sel) => (sel.includes('"9"') ? targetEl : undefined) };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'surbrillance', object_id: 9, valeur: 'on' }, fakeEl(), { id: 1, name: 'Interrupteur' });
|
||||
assert.equal(targetEl.classList.contains('forgeHighlight'), true);
|
||||
@@ -206,8 +235,12 @@ test('forgeRunCollisionRuleAction — "surbrillance" sans objet trouvé (référ
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'surbrillance', object_id: 999, valeur: 'on',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
type: 'surbrillance',
|
||||
object_id: 999,
|
||||
valeur: 'on',
|
||||
then: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; }
|
||||
@@ -223,8 +256,11 @@ test('forgeRunCollisionRuleAction — "son" joue l\'URL fournie via Audio, insta
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'son', asset_url: '/assets/1/bip.mp3',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
type: 'son',
|
||||
asset_url: '/assets/1/bip.mp3',
|
||||
then: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.deepEqual(played, ['/assets/1/bip.mp3']);
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
@@ -238,8 +274,12 @@ test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'video', asset_url: '/assets/1/intro.mp4', mode: 'plein_ecran',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
type: 'video',
|
||||
asset_url: '/assets/1/intro.mp4',
|
||||
mode: 'plein_ecran',
|
||||
then: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, la vidéo "joue"
|
||||
capturedDone();
|
||||
@@ -250,11 +290,14 @@ test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant
|
||||
test('forgeRunCollisionRuleAction — "attendre" suspend "then" jusqu\'à la fin du délai (data_value en secondes)', async () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'attendre', data_value: '0',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
type: 'attendre',
|
||||
data_value: '0',
|
||||
then: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, setTimeout pas encore résolu
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 10); });
|
||||
await new Promise((resolve) => { setTimeout(resolve, 10); });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
});
|
||||
|
||||
@@ -272,16 +315,22 @@ test('forgeRunCollisionRuleAction — "indication" affiche une bulle temporisée
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueur ni tick', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const targetEl = {
|
||||
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
|
||||
removeEventListener() {},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeBindClicSurvolRules({
|
||||
elements: [{
|
||||
id: 5, collision_rules: [{
|
||||
id: 5,
|
||||
collision_rules: [{
|
||||
trigger: 'clic',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
action: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
@@ -291,7 +340,10 @@ test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueu
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur click', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const targetEl = {
|
||||
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
|
||||
removeEventListener() {},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
@@ -302,7 +354,10 @@ test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur cl
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais lié en écouteur DOM (reste géré par le tick)', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const targetEl = {
|
||||
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
|
||||
removeEventListener() {},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
@@ -312,18 +367,24 @@ test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais li
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
const player = {
|
||||
id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' },
|
||||
};
|
||||
const npc = {
|
||||
id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }],
|
||||
};
|
||||
const playerEl = fakeEl();
|
||||
const npcEl = fakeEl();
|
||||
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
npcEl.style = {
|
||||
left: '5px', top: '5px', width: '20px', height: '20px',
|
||||
}; // chevauche playerEl (0,0,20,20)
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
|
||||
global.gameData = { personnage_animations: {} };
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
const calls = [];
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
global.forgeShowDialogueBox = (id) => calls.push(id);
|
||||
try {
|
||||
const screenData = { elements: [player, npc] };
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
||||
@@ -340,17 +401,24 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
// déjà collision.width/height/offset_x/offset_y) doit être utilisé.
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const pnj = {
|
||||
id: 3, kind: 'personnage',
|
||||
id: 3,
|
||||
kind: 'personnage',
|
||||
// Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas
|
||||
// (offset_y positif = décalée vers le bas, voir collision_settings.py).
|
||||
collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 },
|
||||
collision: {
|
||||
enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80,
|
||||
},
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }],
|
||||
};
|
||||
const parent = fakeParentEl();
|
||||
const playerEl = fakeEl(parent);
|
||||
playerEl.style = { left: '45px', top: '175px', width: '20px', height: '20px' }; // chevauche la silhouette réelle du pnj (40,170)-(60,190)
|
||||
playerEl.style = {
|
||||
left: '45px', top: '175px', width: '20px', height: '20px',
|
||||
}; // chevauche la silhouette réelle du pnj (40,170)-(60,190)
|
||||
const pnjEl = fakeEl(parent);
|
||||
pnjEl.style = { left: '0px', top: '0px', width: '100px', height: '200px' }; // grand cadre padé
|
||||
pnjEl.style = {
|
||||
left: '0px', top: '0px', width: '100px', height: '200px',
|
||||
}; // grand cadre padé
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.gameData = { personnage_animations: {} };
|
||||
@@ -365,16 +433,22 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
});
|
||||
|
||||
test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const player = {
|
||||
id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' },
|
||||
};
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] };
|
||||
const parentNear = fakeParentEl();
|
||||
const parentFar = fakeParentEl();
|
||||
const playerElNear = fakeEl(parentNear);
|
||||
const pnjElNear = fakeEl(parentNear);
|
||||
pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' };
|
||||
pnjElNear.style = {
|
||||
left: '5px', top: '5px', width: '20px', height: '20px',
|
||||
};
|
||||
const playerElFar = fakeEl(parentFar);
|
||||
const pnjElFar = fakeEl(parentFar);
|
||||
pnjElFar.style = { left: '300px', top: '300px', width: '20px', height: '20px' };
|
||||
pnjElFar.style = {
|
||||
left: '300px', top: '300px', width: '20px', height: '20px',
|
||||
};
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.gameData = { personnage_animations: {} };
|
||||
|
||||
@@ -79,7 +79,7 @@ test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () =
|
||||
});
|
||||
|
||||
test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => {
|
||||
withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => {
|
||||
withGameData({ data: { 1: [{ id: 7, score: 42 }] } }, () => {
|
||||
window.lastClickedRowId = 7;
|
||||
assert.equal(readFieldValue(1, -1, 'score'), 42);
|
||||
});
|
||||
@@ -93,22 +93,28 @@ test('readVariableValue — lit gameData.variables par nom', () => {
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "objet" (comportement historique)', () => {
|
||||
withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => {
|
||||
const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
withGameData({ data: { 1: [{ id: 5, pv: '3' }] } }, () => {
|
||||
const clause = {
|
||||
cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10',
|
||||
};
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "variable"', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
const clause = {
|
||||
cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10',
|
||||
};
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' };
|
||||
const node = {
|
||||
cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3',
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
@@ -118,8 +124,12 @@ test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)',
|
||||
const node = {
|
||||
cond_combinator: 'et',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '3' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
{
|
||||
source: 'variable', variable: 'pv', operator: 'egal', value: '3',
|
||||
},
|
||||
{
|
||||
source: 'variable', variable: 'or', operator: 'egal', value: '0',
|
||||
},
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
@@ -131,8 +141,12 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
|
||||
const node = {
|
||||
cond_combinator: 'ou',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '999' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
{
|
||||
source: 'variable', variable: 'pv', operator: 'egal', value: '999',
|
||||
},
|
||||
{
|
||||
source: 'variable', variable: 'or', operator: 'egal', value: '0',
|
||||
},
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
@@ -144,45 +158,85 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
|
||||
// collision (= sa boîte visuelle par défaut), configurable (taille,
|
||||
// forme, activée/désactivée).
|
||||
test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => {
|
||||
const box = { left: 10, top: 20, width: 40, height: 60 };
|
||||
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle' });
|
||||
const box = {
|
||||
left: 10, top: 20, width: 40, height: 60,
|
||||
};
|
||||
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), {
|
||||
left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle',
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => {
|
||||
const box = { left: 0, top: 0, width: 100, height: 100 };
|
||||
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 });
|
||||
assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle' });
|
||||
const box = {
|
||||
left: 0, top: 0, width: 100, height: 100,
|
||||
};
|
||||
const rect = forgeCollisionRectFromBox(box, {
|
||||
width: 20, height: 20, offset_x: 0, offset_y: 0,
|
||||
});
|
||||
assert.deepEqual(rect, {
|
||||
left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle',
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => {
|
||||
const box = { left: 0, top: 0, width: 100, height: 100 };
|
||||
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 });
|
||||
assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle' });
|
||||
const box = {
|
||||
left: 0, top: 0, width: 100, height: 100,
|
||||
};
|
||||
const rect = forgeCollisionRectFromBox(box, {
|
||||
width: 20, height: 20, offset_x: 15, offset_y: -5,
|
||||
});
|
||||
assert.deepEqual(rect, {
|
||||
left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle',
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => {
|
||||
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 5, top: 5, right: 15, bottom: 15 }), true);
|
||||
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 20, top: 20, right: 30, bottom: 30 }), false);
|
||||
assert.equal(forgeRectanglesOverlap({
|
||||
left: 0, top: 0, right: 10, bottom: 10,
|
||||
}, {
|
||||
left: 5, top: 5, right: 15, bottom: 15,
|
||||
}), true);
|
||||
assert.equal(forgeRectanglesOverlap({
|
||||
left: 0, top: 0, right: 10, bottom: 10,
|
||||
}, {
|
||||
left: 20, top: 20, right: 30, bottom: 30,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => {
|
||||
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20 }; // centre (10,10), rayon 10
|
||||
const touching = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20 }; // centre (25,10), rayon 10, distance 15 < 20
|
||||
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20 };
|
||||
const a = {
|
||||
left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20,
|
||||
}; // centre (10,10), rayon 10
|
||||
const touching = {
|
||||
left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20,
|
||||
}; // centre (25,10), rayon 10, distance 15 < 20
|
||||
const apart = {
|
||||
left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20,
|
||||
};
|
||||
assert.equal(forgeCirclesOverlap(a, touching), true);
|
||||
assert.equal(forgeCirclesOverlap(a, apart), false);
|
||||
});
|
||||
|
||||
test('forgeShapesOverlap — deux cercles utilisent le test cercle-cercle', () => {
|
||||
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle' };
|
||||
const b = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle' };
|
||||
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle' };
|
||||
const a = {
|
||||
left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle',
|
||||
};
|
||||
const b = {
|
||||
left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle',
|
||||
};
|
||||
const apart = {
|
||||
left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle',
|
||||
};
|
||||
assert.equal(forgeShapesOverlap(a, b), true);
|
||||
assert.equal(forgeShapesOverlap(a, apart), false);
|
||||
});
|
||||
|
||||
test('forgeShapesOverlap — une paire rectangle/cercle retombe sur le test rectangle-rectangle', () => {
|
||||
const rect = { left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle' };
|
||||
const circle = { left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle' };
|
||||
const rect = {
|
||||
left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle',
|
||||
};
|
||||
const circle = {
|
||||
left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle',
|
||||
};
|
||||
assert.equal(forgeShapesOverlap(rect, circle), true); // les rectangles englobants se chevauchent
|
||||
});
|
||||
|
||||
@@ -28,18 +28,25 @@ function fakeWidget(objectId) {
|
||||
};
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c),
|
||||
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, force) => {
|
||||
const on = force === undefined ? !classes.has(c) : !!force;
|
||||
if (on) classes.add(c); else classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
querySelector: sel => {
|
||||
querySelector: (sel) => {
|
||||
if (sel === '[data-dialogue-role="header"]') return roles.header;
|
||||
if (sel === '[data-dialogue-role="body"]') return roles.body;
|
||||
if (sel === '[data-dialogue-role="footer"]') return roles.footer;
|
||||
return null;
|
||||
},
|
||||
_roles: roles, _classes: classes,
|
||||
_roles: roles,
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,16 +57,16 @@ 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 = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) };
|
||||
const attrs = {};
|
||||
const classes = new Set();
|
||||
const bodyChildren = [];
|
||||
const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } };
|
||||
const body = { appendChild: (el) => { bodyChildren.push(el); el.parentNode = body; } };
|
||||
const widget = {
|
||||
parentNode: null,
|
||||
closest: sel => (sel === '.playScreen' ? screenEl : null),
|
||||
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
classList: { add: c => classes.add(c) },
|
||||
classList: { add: (c) => classes.add(c) },
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { body };
|
||||
@@ -108,7 +115,7 @@ test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet'
|
||||
try {
|
||||
forgeShowDialogueBox('d_6', 'PNJ', []);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_6, undefined);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -119,7 +126,7 @@ test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dia
|
||||
try {
|
||||
forgeShowDialogueBox('d_6', 'PNJ', [], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_6, true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -134,7 +141,7 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
global.document = { querySelector: (sel) => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]);
|
||||
assert.equal(widget._roles.header.textContent, 'PNJ');
|
||||
@@ -182,7 +189,7 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — la voix off de la r
|
||||
const paused = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
return { url: url, play: () => Promise.resolve(), pause: function () { paused.push(this.url); } };
|
||||
return { url, play: () => Promise.resolve(), pause() { paused.push(this.url); } };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [
|
||||
@@ -202,7 +209,7 @@ test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué te
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_5, undefined);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -217,7 +224,7 @@ test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propa
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_5, true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété
|
||||
} finally { global.document = previousDoc; }
|
||||
@@ -231,7 +238,9 @@ test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiateme
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
const thenAction = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
};
|
||||
forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
@@ -247,7 +256,9 @@ test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la derni
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
const thenAction = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
};
|
||||
forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction);
|
||||
assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant
|
||||
@@ -268,14 +279,21 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
|
||||
|
||||
function fakeQuizChoiceBtn() {
|
||||
const classes = new Set();
|
||||
return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes };
|
||||
return {
|
||||
disabled: false,
|
||||
classList: { add: (c) => classes.add(c), contains: (c) => classes.has(c) },
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
attrs = attrs || {};
|
||||
const roles = {
|
||||
header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' },
|
||||
score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
|
||||
header: { textContent: '' },
|
||||
question: { textContent: '' },
|
||||
choices: { innerHTML: '' },
|
||||
score: { textContent: '' },
|
||||
timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
|
||||
};
|
||||
// Posé désactivé par forgeShowQuizBox dès la question (jamais recréé
|
||||
// par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir
|
||||
@@ -294,8 +312,8 @@ 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 => {
|
||||
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;
|
||||
@@ -305,11 +323,16 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
||||
// (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen).
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
|
||||
add: (...cs) => cs.forEach((c) => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach((c) => classes.delete(c)),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, force) => {
|
||||
const on = force === undefined ? !classes.has(c) : !!force;
|
||||
if (on) classes.add(c); else classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
querySelector: sel => {
|
||||
querySelector: (sel) => {
|
||||
if (sel === '[data-quiz-role="header"]') return roles.header;
|
||||
if (sel === '[data-quiz-role="question"]') return roles.question;
|
||||
if (sel === '[data-quiz-role="choices"]') return roles.choices;
|
||||
@@ -318,9 +341,12 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
if (sel === '.quizBoxContinueBtn') return continueBtn;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
|
||||
querySelectorAll: (sel) => (sel === '.quizBoxChoiceBtn' ? buttons : []),
|
||||
offsetWidth: 100,
|
||||
_roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn,
|
||||
_roles: roles,
|
||||
_classes: classes,
|
||||
_buttons: buttons,
|
||||
_continueBtn: continueBtn,
|
||||
};
|
||||
return widget;
|
||||
}
|
||||
@@ -351,7 +377,9 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
|
||||
const QUIZ_LINES = [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{
|
||||
type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5,
|
||||
},
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
];
|
||||
|
||||
@@ -372,7 +400,7 @@ function docWithQuizOnlyNoDialogueBox(quizWidget) {
|
||||
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null;
|
||||
return null; // jamais de ".dialogueBoxWidget" sur cet écran
|
||||
},
|
||||
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
|
||||
querySelectorAll: (sel) => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
|
||||
body: { appendChild: () => {} },
|
||||
};
|
||||
}
|
||||
@@ -383,7 +411,9 @@ test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) a
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_30', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?');
|
||||
@@ -396,12 +426,14 @@ test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (m
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_31', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
], true); // markCompleted
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 1);
|
||||
forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_31'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_31, true);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -430,7 +462,9 @@ test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_50', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question
|
||||
forgeQuizBoxContinue(99);
|
||||
@@ -449,8 +483,12 @@ test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de ré
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_51', 'Quiz', [
|
||||
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
{
|
||||
type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin
|
||||
@@ -470,7 +508,9 @@ test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PU
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_52', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif
|
||||
@@ -495,7 +535,9 @@ test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (naviga
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_54', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99);
|
||||
@@ -514,15 +556,21 @@ test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => {
|
||||
|
||||
test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const quizWidget = fakeQuizWidget(99, 2, {
|
||||
'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique',
|
||||
});
|
||||
const previousDoc = global.document;
|
||||
let completeCalledWith = null;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_53', 'Quiz', [
|
||||
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
{
|
||||
type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxTimeout(99, true); // encore la question 2 à venir
|
||||
t.mock.timers.tick(1500);
|
||||
@@ -552,7 +600,7 @@ 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);
|
||||
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
@@ -627,7 +675,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_10, true);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -733,9 +781,9 @@ test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS ter
|
||||
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s)
|
||||
t.mock.timers.tick(5000); // expire : révèle la bonne réponse
|
||||
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_20, undefined); // pas encore, le délai de lecture tourne
|
||||
t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_20, true);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
@@ -760,7 +808,7 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 42;
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
|
||||
querySelectorAll: (sel) => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
|
||||
};
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
@@ -777,7 +825,7 @@ test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS un
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 7;
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
|
||||
querySelectorAll: (sel) => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
|
||||
};
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
@@ -849,7 +897,7 @@ test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () =>
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
try {
|
||||
forgeCompleteDialogue('d_20', 'Crokine');
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_20, true);
|
||||
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
} finally { delete global.forgeXapiNotifyDialogueCompleted; }
|
||||
@@ -900,20 +948,24 @@ 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),
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelector: (sel) => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score });
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({
|
||||
dialogueId, objectName, index, text, correct, score,
|
||||
});
|
||||
try {
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5,
|
||||
}]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
@@ -927,21 +979,32 @@ 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),
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelector: (sel) => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (
|
||||
dialogueId,
|
||||
objectName,
|
||||
index,
|
||||
text,
|
||||
correct,
|
||||
choiceIndex,
|
||||
correctIndex,
|
||||
) => calls.push({
|
||||
dialogueId, objectName, index, text, correct, choiceIndex, correctIndex,
|
||||
});
|
||||
try {
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5,
|
||||
}]);
|
||||
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
|
||||
@@ -12,6 +12,7 @@ global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
|
||||
const {
|
||||
@@ -19,14 +20,19 @@ const {
|
||||
forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, forgeWouldCollide,
|
||||
} = require('../personnage-controller.js');
|
||||
|
||||
const { stopAllSpriteAnimations } = actionsMod;
|
||||
|
||||
function fakeSceneEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
style: { left: '100px', top: '100px', width: '20px', height: '20px' },
|
||||
style: {
|
||||
left: '100px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: c => (classes.has(c) ? classes.delete(c) : classes.add(c)),
|
||||
contains: c => classes.has(c),
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
toggle: (c) => (classes.has(c) ? classes.delete(c) : classes.add(c)),
|
||||
contains: (c) => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,13 +43,12 @@ function withPersonnageObject(obj, elByObjectId, fn) {
|
||||
// (cherche ".sceneWorld", jamais présente dans elByObjectId ici) — un
|
||||
// sélecteur qui ne matche aucun id numérique renvoie undefined plutôt
|
||||
// que de lever, comme un vrai document.querySelector sans résultat.
|
||||
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
|
||||
global.document = { querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
|
||||
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
|
||||
global.window.currentScreenId = 1;
|
||||
global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } };
|
||||
try { fn(); } finally { global.document = previousDoc; stopAllSpriteAnimations(); }
|
||||
}
|
||||
const { stopAllSpriteAnimations } = actionsMod;
|
||||
|
||||
test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux si la pose est absente', () => {
|
||||
global.gameData = { personnage_animations: { 5: { idle: ['/i0.png'] } } };
|
||||
@@ -53,7 +58,14 @@ test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux s
|
||||
});
|
||||
|
||||
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
|
||||
const obj = { id: 10, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 10,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 10: el }, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
@@ -64,7 +76,14 @@ test('tick — touche "droite" enfoncée : déplace vers la droite et retire le
|
||||
});
|
||||
|
||||
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
|
||||
const obj = { id: 11, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 11,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 11: el }, () => {
|
||||
global.heldKeys = new Set(['q']);
|
||||
@@ -75,7 +94,14 @@ test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique l
|
||||
});
|
||||
|
||||
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
|
||||
const obj = { id: 12, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 12,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 12: el }, () => {
|
||||
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
|
||||
@@ -85,7 +111,14 @@ test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfonc
|
||||
});
|
||||
|
||||
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
|
||||
const obj = { id: 13, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 13,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 13: el }, () => {
|
||||
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
|
||||
@@ -95,7 +128,14 @@ test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfo
|
||||
});
|
||||
|
||||
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
|
||||
const obj = { id: 14, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
|
||||
const obj = {
|
||||
id: 14,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
|
||||
withPersonnageObject(obj, { 14: el }, () => {
|
||||
@@ -106,7 +146,14 @@ test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () =
|
||||
});
|
||||
|
||||
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
|
||||
const obj = { id: 15, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 15,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 15: el }, () => {
|
||||
global.heldKeys = new Set();
|
||||
@@ -131,7 +178,14 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman
|
||||
// clavier, "ennemie"/"pnj" doivent rester immobiles tant qu'aucune
|
||||
// logique de flow ne les pilote.
|
||||
test('tick — un personnage "ennemie"/"pnj" n\'est jamais déplacé au clavier', () => {
|
||||
const ennemi = { id: 17, kind: 'personnage', personnage_role: 'ennemie', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const ennemi = {
|
||||
id: 17,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'ennemie',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(ennemi, { 17: el }, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
@@ -165,7 +219,14 @@ function fakeWorldEl() {
|
||||
}
|
||||
|
||||
test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond ne dépasse (comportement inchangé)', () => {
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'decor', x: 10, y: 10, width: 50, height: 50 }] };
|
||||
const screenData = {
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [{
|
||||
kind: 'decor', x: 10, y: 10, width: 50, height: 50,
|
||||
}],
|
||||
};
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
assert.deepEqual(world, { width: 500, height: 500 });
|
||||
assert.equal(screenData.world_width, 500);
|
||||
@@ -173,7 +234,14 @@ test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond
|
||||
});
|
||||
|
||||
test('forgeUpdateSceneWorldSize — un fond plus grand agrandit le monde jusqu\'à ses propres bords', () => {
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }] };
|
||||
const screenData = {
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [{
|
||||
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
|
||||
}],
|
||||
};
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
assert.deepEqual(world, { width: 3840, height: 2160 });
|
||||
});
|
||||
@@ -182,7 +250,9 @@ test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, co
|
||||
const worldEl = fakeWorldEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => worldEl };
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [] };
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500, elements: [],
|
||||
};
|
||||
try {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
assert.equal(worldEl.style.width, '500px');
|
||||
@@ -193,15 +263,23 @@ test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, co
|
||||
|
||||
test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux bords du monde', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const playerEl = { style: { left: '1900px', top: '1000px', width: '40px', height: '60px' } };
|
||||
const playerEl = {
|
||||
style: {
|
||||
left: '1900px', top: '1000px', width: '40px', height: '60px',
|
||||
},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
querySelector: (sel) => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
};
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500,
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [
|
||||
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
|
||||
{
|
||||
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
|
||||
},
|
||||
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
|
||||
],
|
||||
};
|
||||
@@ -217,15 +295,23 @@ test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux b
|
||||
|
||||
test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (bornage)', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const playerEl = { style: { left: '3820px', top: '0px', width: '20px', height: '20px' } }; // tout au bord droit
|
||||
const playerEl = {
|
||||
style: {
|
||||
left: '3820px', top: '0px', width: '20px', height: '20px',
|
||||
},
|
||||
}; // tout au bord droit
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
querySelector: (sel) => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
};
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500,
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [
|
||||
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
|
||||
{
|
||||
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
|
||||
},
|
||||
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
|
||||
],
|
||||
};
|
||||
@@ -244,7 +330,7 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b
|
||||
// chargement) — un test document mock dédié, avec plusieurs éléments.
|
||||
function withScene(objectsById, screenData, fn) {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } };
|
||||
global.document = { querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } };
|
||||
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
|
||||
global.window.currentScreenId = 1;
|
||||
try { fn(); } finally { global.document = previousDoc; }
|
||||
@@ -252,7 +338,11 @@ function withScene(objectsById, screenData, fn) {
|
||||
|
||||
test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approche', () => {
|
||||
const player = { id: 20, kind: 'personnage' };
|
||||
const wallEl = { style: { left: '140px', top: '100px', width: '20px', height: '20px' } };
|
||||
const wallEl = {
|
||||
style: {
|
||||
left: '140px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
const screenData = { elements: [player, { id: 21, kind: 'decor' }] };
|
||||
withScene({ 21: wallEl }, screenData, () => {
|
||||
// Le joueur (20x20) tente d'aller en (130,100) -> chevauche le mur (140..160, 100..120).
|
||||
@@ -264,8 +354,16 @@ test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approch
|
||||
|
||||
test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor à collision désactivée', () => {
|
||||
const player = { id: 22, kind: 'personnage' };
|
||||
const bgEl = { style: { left: '0px', top: '0px', width: '3840px', height: '2160px' } };
|
||||
const disabledEl = { style: { left: '130px', top: '100px', width: '20px', height: '20px' } };
|
||||
const bgEl = {
|
||||
style: {
|
||||
left: '0px', top: '0px', width: '3840px', height: '2160px',
|
||||
},
|
||||
};
|
||||
const disabledEl = {
|
||||
style: {
|
||||
left: '130px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
const screenData = {
|
||||
elements: [
|
||||
player,
|
||||
@@ -279,10 +377,21 @@ test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor
|
||||
});
|
||||
|
||||
test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais dans le mur)', () => {
|
||||
const player = { id: 25, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const player = {
|
||||
id: 25,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const wall = { id: 26, kind: 'decor' };
|
||||
const playerEl = fakeSceneEl(); // left/top 100px, 20x20
|
||||
const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } };
|
||||
const wallEl = {
|
||||
style: {
|
||||
left: '122px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
withPersonnageObject(player, { 25: playerEl, 26: wallEl }, () => {
|
||||
global.heldKeys = new Set(['d']); // vers la droite, vers le mur juste à côté
|
||||
forgeRunPersonnageControllerTick({ elements: [player, wall] });
|
||||
@@ -291,14 +400,25 @@ test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais
|
||||
});
|
||||
|
||||
test('tick — glisse le long d\'un mur en diagonale (un seul axe bloqué)', () => {
|
||||
const player = { id: 27, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const player = {
|
||||
id: 27,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const wall = { id: 28, kind: 'decor' };
|
||||
const playerEl = fakeSceneEl(); // left/top 100px, 20x20
|
||||
const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; // bloque seulement à droite
|
||||
const wallEl = {
|
||||
style: {
|
||||
left: '122px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
}; // bloque seulement à droite
|
||||
withPersonnageObject(player, { 27: playerEl, 28: wallEl }, () => {
|
||||
global.heldKeys = new Set(['d', 's']); // droite (bloquée) + bas (libre)
|
||||
forgeRunPersonnageControllerTick({ elements: [player, wall] });
|
||||
assert.equal(playerEl.style.left, '100px'); // bloqué
|
||||
assert.equal(playerEl.style.top, '104px'); // continue de descendre
|
||||
assert.equal(playerEl.style.top, '104px'); // continue de descendre
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,11 @@ function fakeMangaWidget() {
|
||||
if (sel === '[data-manga-role="result"]') return resultZone;
|
||||
return null;
|
||||
},
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
_header: header, _progress: progress, _resultZone: resultZone, _classes: classes,
|
||||
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
|
||||
_header: header,
|
||||
_progress: progress,
|
||||
_resultZone: resultZone,
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +126,9 @@ test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous
|
||||
|
||||
test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => {
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(
|
||||
() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow)
|
||||
@@ -139,7 +144,8 @@ function fakeClassiqueWidget() {
|
||||
if (sel === '[data-classique-role="progress"]') return progress;
|
||||
return null;
|
||||
},
|
||||
_header: header, _progress: progress,
|
||||
_header: header,
|
||||
_progress: progress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,7 +171,13 @@ test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => {
|
||||
|
||||
function fakeClassiqueWidgetForResult() {
|
||||
const contentClasses = new Set();
|
||||
const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } };
|
||||
const content = {
|
||||
classList: {
|
||||
add: (c) => contentClasses.add(c),
|
||||
remove: (c) => contentClasses.delete(c),
|
||||
contains: (c) => contentClasses.has(c),
|
||||
},
|
||||
};
|
||||
const resultZone = { innerHTML: '' };
|
||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||
return {
|
||||
@@ -176,7 +188,10 @@ function fakeClassiqueWidgetForResult() {
|
||||
if (sel === '.classiqueHeaderTitle') return headerTitle;
|
||||
return null;
|
||||
},
|
||||
_content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle,
|
||||
_content: content,
|
||||
_contentClasses: contentClasses,
|
||||
_resultZone: resultZone,
|
||||
_headerTitle: headerTitle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +226,9 @@ test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil
|
||||
|
||||
test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => {
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(
|
||||
() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
@@ -38,7 +39,9 @@ test('forgeRunScreenTriggers — ignore tout déclencheur autre que "affichage"
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'collision',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
action: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.score.value, '0');
|
||||
|
||||
@@ -14,11 +14,17 @@ const { isGameKey } = require('../triggers.js');
|
||||
test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/touche_maintenue/touche_relachee', () => {
|
||||
global.gameData = {
|
||||
flows: {
|
||||
'1': {
|
||||
1: {
|
||||
nodes: [
|
||||
{ id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ' },
|
||||
{ id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight' },
|
||||
{ id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft' },
|
||||
{
|
||||
id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ',
|
||||
},
|
||||
{
|
||||
id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight',
|
||||
},
|
||||
{
|
||||
id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
@@ -31,6 +37,15 @@ test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/t
|
||||
});
|
||||
|
||||
test('isGameKey — ignore les touches d\'un autre type de déclencheur (ex. "Au clic")', () => {
|
||||
global.gameData = { flows: { '1': { nodes: [{ id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5 }], edges: [] } } };
|
||||
global.gameData = {
|
||||
flows: {
|
||||
1: {
|
||||
nodes: [{
|
||||
id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5,
|
||||
}],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(isGameKey('Enter'), false);
|
||||
});
|
||||
|
||||
+67
-62
@@ -7,7 +7,7 @@
|
||||
|
||||
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
|
||||
// pilotable par une action "Modifier un élément".
|
||||
var ELEMENT_PROPERTY_CSS = {
|
||||
const ELEMENT_PROPERTY_CSS = {
|
||||
bg_color: { css: 'backgroundColor', suffix: '' },
|
||||
border_color: { css: 'borderColor', suffix: '' },
|
||||
width: { css: 'width', suffix: '%' },
|
||||
@@ -97,7 +97,7 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// toujours au même endroit.
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = parseFloat(value);
|
||||
if (isNaN(delta)) return;
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
return;
|
||||
@@ -113,7 +113,7 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = 'toggle' + property;
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
@@ -137,7 +137,7 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// pour un objet de scène), la valeur n'est pas bornée plutôt que de
|
||||
// risquer un blocage à 0.
|
||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||||
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;
|
||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||
@@ -156,15 +156,15 @@ 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);
|
||||
if (isNaN(delta)) return;
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[css]) || 0;
|
||||
targetEl.style[css] = clampSceneObjectPosition(targetEl, css, current + delta) + 'px';
|
||||
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, parseFloat(value) || 0)}px`;
|
||||
return;
|
||||
}
|
||||
applyElementProperty(targetEl, property, value);
|
||||
@@ -175,14 +175,14 @@ function runActionNode(node) {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ecran_precedent') {
|
||||
} if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
} if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
} 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
|
||||
@@ -205,27 +205,27 @@ function runActionNode(node) {
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||
} 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();
|
||||
} else if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||
} 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) + '"]');
|
||||
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();
|
||||
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
} 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();
|
||||
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
} 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
|
||||
@@ -234,31 +234,30 @@ function runActionNode(node) {
|
||||
// (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(function(s){ return s.id === window.currentScreenId; });
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter(function(e){ return (e.parent_id || null) === parentKey; })
|
||||
.forEach(function(e) {
|
||||
const el = document.querySelector('[data-element-id="' + e.id + '"]');
|
||||
.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'; }
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'attendre') {
|
||||
} 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 = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
} else if (node.action_type === 'modifier_donnee') {
|
||||
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.
|
||||
@@ -272,21 +271,22 @@ function runActionNode(node) {
|
||||
// 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 })
|
||||
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((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'modifier_variable') {
|
||||
} 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())
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'modifier_score') {
|
||||
} 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
|
||||
@@ -296,19 +296,19 @@ function runActionNode(node) {
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'definir_statut_partie') {
|
||||
} 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())
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'ajouter_ligne') {
|
||||
} 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,
|
||||
@@ -320,21 +320,21 @@ function runActionNode(node) {
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(function(json) {
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
} else if (node.action_type === 'jouer_son') {
|
||||
} 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(function() {});
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {});
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||
} 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 + '"]');
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
||||
@@ -359,7 +359,7 @@ function runActionNode(node) {
|
||||
// ci-dessus (événementiel) ET un clip de la Timeline d'animation de kind
|
||||
// "sprite" (déclaratif, voir applyAnimationClip() dans screens.js) — les
|
||||
// deux partagent ce même moteur, aucune logique dupliquée.
|
||||
var activeSpriteAnimations = new Map();
|
||||
const activeSpriteAnimations = new Map();
|
||||
|
||||
// Phase 8 — résout le NOM d'une animation (ex. "walk") en liste de frames à
|
||||
// partir du personnage ACTUELLEMENT assigné à cet élément
|
||||
@@ -373,7 +373,7 @@ var 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) {
|
||||
var 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 };
|
||||
}
|
||||
@@ -404,11 +404,11 @@ function resolveSpriteFrames(elementId, spriteData) {
|
||||
// aussitôt posée si on ne la protégeait pas explicitement — un setTimeout
|
||||
// dédié la maintient "en cours" un court instant avant de la libérer.
|
||||
function runSpriteAnimation(targetEl, spriteData) {
|
||||
var frames = spriteData.frames || [];
|
||||
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||||
var loop = !!spriteData.loop;
|
||||
var signature = frames.join('|') + '@' + fps + (loop ? '+loop' : '');
|
||||
var active = activeSpriteAnimations.get(targetEl);
|
||||
const frames = spriteData.frames || [];
|
||||
const fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||||
const loop = !!spriteData.loop;
|
||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||
const active = activeSpriteAnimations.get(targetEl);
|
||||
if (active && active.signature === signature) return;
|
||||
if (active && !active.loop) return;
|
||||
if (active) {
|
||||
@@ -416,16 +416,16 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
activeSpriteAnimations.delete(targetEl);
|
||||
}
|
||||
if (!frames.length) return;
|
||||
var frameIndex = 0;
|
||||
let frameIndex = 0;
|
||||
targetEl.src = frames[0];
|
||||
if (frames.length === 1) {
|
||||
if (loop) return;
|
||||
var holdMs = Math.max(1000 / fps, 150);
|
||||
var timeoutId = setTimeout(function() { activeSpriteAnimations.delete(targetEl); }, holdMs);
|
||||
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature: signature, loop: loop });
|
||||
const holdMs = Math.max(1000 / fps, 150);
|
||||
const timeoutId = setTimeout(() => { activeSpriteAnimations.delete(targetEl); }, holdMs);
|
||||
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature, loop });
|
||||
return;
|
||||
}
|
||||
var intervalId = setInterval(function() {
|
||||
const intervalId = setInterval(() => {
|
||||
frameIndex++;
|
||||
if (frameIndex >= frames.length) {
|
||||
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
|
||||
@@ -433,7 +433,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
}
|
||||
targetEl.src = frames[frameIndex];
|
||||
}, 1000 / fps);
|
||||
activeSpriteAnimations.set(targetEl, { intervalId: intervalId, signature: signature, loop: loop });
|
||||
activeSpriteAnimations.set(targetEl, { intervalId, signature, loop });
|
||||
}
|
||||
|
||||
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
|
||||
@@ -441,14 +441,19 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
// directement depuis screens.js, même patron que runScreenTimerTriggers()
|
||||
// dans triggers.js (l'appelant ne connaît jamais la structure interne).
|
||||
function stopAllSpriteAnimations() {
|
||||
activeSpriteAnimations.forEach(function(active) { clearInterval(active.intervalId); });
|
||||
activeSpriteAnimations.forEach((active) => { clearInterval(active.intervalId); });
|
||||
activeSpriteAnimations.clear();
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
resolveSpriteFrames,
|
||||
runSpriteAnimation,
|
||||
stopAllSpriteAnimations,
|
||||
activeSpriteAnimations,
|
||||
applyObjectProperty,
|
||||
clampSceneObjectPosition,
|
||||
runActionNode,
|
||||
};
|
||||
}
|
||||
|
||||
+168
-167
@@ -17,15 +17,15 @@
|
||||
// statique jamais passé par Jinja, ce n'est plus nécessaire, mais la
|
||||
// construction est conservée pour rester visuellement cohérente avec le
|
||||
// reste du moteur et éviter toute confusion en relisant le code.
|
||||
var PLACEHOLDER_OPEN = '{' + '{';
|
||||
var PLACEHOLDER_CLOSE = '}' + '}';
|
||||
const PLACEHOLDER_OPEN = '{' + '{';
|
||||
const PLACEHOLDER_CLOSE = '}' + '}';
|
||||
|
||||
function applyOpenRowBindings() {
|
||||
if (!window.currentOpenRow) return;
|
||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||
const row = rows.find(r => r.id === window.currentOpenRow.rowId);
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
const row = rows.find((r) => r.id === window.currentOpenRow.rowId);
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"]`);
|
||||
if (!row || !screenData || !screenDiv) return;
|
||||
|
||||
// Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les
|
||||
@@ -51,7 +51,7 @@ function applyOpenRowBindings() {
|
||||
// le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine
|
||||
// de l'écran, annulant complètement l'effet recherché par ce correctif.
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
function hasUnresolvedPlaceholder(e) {
|
||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
||||
}
|
||||
@@ -64,9 +64,9 @@ function applyOpenRowBindings() {
|
||||
return false;
|
||||
}
|
||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||
const targets = candidates.filter(function(e) {
|
||||
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
});
|
||||
const targets = candidates.filter(
|
||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)),
|
||||
);
|
||||
|
||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
|
||||
@@ -85,7 +85,7 @@ function applyOpenRowBindings() {
|
||||
// élément", donnant l'impression trompeuse d'un rechargement de page).
|
||||
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
|
||||
// rien à sauvegarder ni à restaurer.
|
||||
const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g');
|
||||
const placeholderRe = new RegExp(`${PLACEHOLDER_OPEN}([^{}]+)${PLACEHOLDER_CLOSE}`, 'g');
|
||||
|
||||
function resolveTextNodes(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
@@ -94,11 +94,12 @@ function applyOpenRowBindings() {
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
||||
}
|
||||
toSplit.forEach(function(textNode) {
|
||||
toSplit.forEach((textNode) => {
|
||||
const raw = textNode.nodeValue;
|
||||
placeholderRe.lastIndex = 0;
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIndex = 0, match;
|
||||
let lastIndex = 0; let
|
||||
match;
|
||||
while ((match = placeholderRe.exec(raw))) {
|
||||
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
|
||||
const span = document.createElement('span');
|
||||
@@ -113,31 +114,31 @@ function applyOpenRowBindings() {
|
||||
}
|
||||
|
||||
function updateTextSlots(root) {
|
||||
root.querySelectorAll('[data-bind-field]').forEach(function(slot) {
|
||||
root.querySelectorAll('[data-bind-field]').forEach((slot) => {
|
||||
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
|
||||
if (slot.textContent !== value) slot.textContent = value;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAttributes(root) {
|
||||
[root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) {
|
||||
Array.from(node.attributes).forEach(function(attr) {
|
||||
[root].concat(Array.from(root.querySelectorAll('*'))).forEach((node) => {
|
||||
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);
|
||||
const bindKey = `data-bind-attr-${attr.name}`;
|
||||
const template = node.hasAttribute(bindKey)
|
||||
? node.getAttribute(bindKey)
|
||||
: (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
if (template === null) return;
|
||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||
placeholderRe.lastIndex = 0;
|
||||
const resolved = template.replace(placeholderRe, function(_, fieldName) {
|
||||
return row[fieldName] == null ? '' : String(row[fieldName]);
|
||||
});
|
||||
const resolved = template.replace(placeholderRe, (_, fieldName) => (row[fieldName] == null ? '' : String(row[fieldName])));
|
||||
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
targets.forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
targets.forEach((el) => {
|
||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||
if (!target) return;
|
||||
if (target.querySelector('[data-bind-field]')) {
|
||||
updateTextSlots(target);
|
||||
@@ -167,156 +168,156 @@ function refreshRuntimeData() {
|
||||
// en ligne comme hors ligne.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeRecomputeFullPayloadOffline(gameData);
|
||||
return Promise.resolve().then(function () { return _forgeApplyFreshRuntimeData(gameData); });
|
||||
return Promise.resolve().then(() => _forgeApplyFreshRuntimeData(gameData));
|
||||
}
|
||||
return fetch(FORGE_PLAY_URLS.runtimePayload)
|
||||
.then(r => r.json())
|
||||
.then((r) => r.json())
|
||||
.then(_forgeApplyFreshRuntimeData);
|
||||
}
|
||||
|
||||
function _forgeApplyFreshRuntimeData(newData) {
|
||||
gameData = newData;
|
||||
screensData = gameData.screens;
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
||||
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
||||
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
||||
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
||||
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
||||
// de clic pour le reste de la partie (plus aucune ligne ne
|
||||
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
||||
// risque à rappeler même quand rien n'a changé.
|
||||
if (screenData && screenDiv) {
|
||||
// try/catch : une erreur inattendue pendant la régénération (ex.
|
||||
// structure de données imprévue) ne doit jamais empêcher les
|
||||
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
||||
// tout autre élément cliquable) reste silencieusement inerte pour
|
||||
// le reste de la partie, sans le moindre message d'erreur visible.
|
||||
try {
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée), "visibilityGated" (condition de visibilité) et
|
||||
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
|
||||
// render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
//
|
||||
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
||||
// (pas seulement le premier niveau de l'écran), via leur
|
||||
// data-element-id — disponible sur chaque élément rendu, pas
|
||||
// seulement les éléments de premier niveau. Avant ce correctif,
|
||||
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
||||
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
||||
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
||||
// et se retrouvait régénéré en entier à sa place — potentiellement
|
||||
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine.
|
||||
//
|
||||
// Parmi les éléments marqués, on ne garde que les plus
|
||||
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
||||
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
||||
// son rendered_html contient celui de ses descendants — le
|
||||
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
||||
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
||||
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
||||
// gardait en pratique que l'ancêtre racine de l'écran et
|
||||
// annulait complètement l'effet recherché.)
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
function hasMarker(e) {
|
||||
return !!(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
|
||||
));
|
||||
}
|
||||
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;
|
||||
}
|
||||
// "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
|
||||
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
|
||||
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
|
||||
// comportement déjà correct pour ces deux-là). Repérer ce
|
||||
// commentaire permet de retrouver, à l'intérieur d'un fragment
|
||||
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
|
||||
// est niché dans un exemplaire d'élément de jeu réutilisable
|
||||
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
|
||||
// dialogue), où lui seul porte l'attribut data-element-id qui
|
||||
// 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'];
|
||||
function findCommentMarkedDescendants(root) {
|
||||
const found = [];
|
||||
root.querySelectorAll('[data-element-id]').forEach(function(node) {
|
||||
const sib = node.nextSibling;
|
||||
const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
// commentaire marqueur à sa suite — sa balise porte
|
||||
// directement la classe "visibilityGated" à la place. Sans
|
||||
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
|
||||
// n'était jamais retrouvé ici (seul le sens caché->visible
|
||||
// l'était, via le commentaire) : son ancien nœud visible
|
||||
// restait donc affiché indéfiniment à côté du nouveau,
|
||||
// maintenant visible, qui le remplace normalement (ex. une
|
||||
// icône "enveloppe fermée" jamais masquée en même temps que
|
||||
// "enveloppe ouverte" qui apparaît après avoir cliqué).
|
||||
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
|
||||
if (hasCommentMarker || isHiddenPlaceholder) {
|
||||
found.push(node);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates.filter(function(e) {
|
||||
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
}).forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
if (!target) return;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = el.rendered_html;
|
||||
const newNode = container.firstElementChild;
|
||||
if (!newNode) return;
|
||||
const innerMarked = findCommentMarkedDescendants(newNode);
|
||||
if (innerMarked.length) {
|
||||
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
|
||||
// Texte "Donnée liée" posé dans un élément de jeu
|
||||
// réutilisable) : on ne patche QUE ceux-là, en préservant
|
||||
// tout le reste — une boîte de dialogue ne doit ni
|
||||
// disparaître ni réapparaître juste parce qu'un texte à
|
||||
// l'intérieur a changé.
|
||||
innerMarked.forEach(function(innerNode) {
|
||||
const innerId = innerNode.getAttribute('data-element-id');
|
||||
const liveInner = target.querySelector('[data-element-id="' + innerId + '"]');
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
} else {
|
||||
target.replaceWith(newNode);
|
||||
}
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
} catch (err) {
|
||||
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
||||
gameData = newData;
|
||||
screensData = gameData.screens;
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"]`);
|
||||
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
||||
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
||||
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
||||
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
||||
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
||||
// de clic pour le reste de la partie (plus aucune ligne ne
|
||||
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
||||
// risque à rappeler même quand rien n'a changé.
|
||||
if (screenData && screenDiv) {
|
||||
// try/catch : une erreur inattendue pendant la régénération (ex.
|
||||
// structure de données imprévue) ne doit jamais empêcher les
|
||||
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
||||
// tout autre élément cliquable) reste silencieusement inerte pour
|
||||
// le reste de la partie, sans le moindre message d'erreur visible.
|
||||
try {
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée), "visibilityGated" (condition de visibilité) et
|
||||
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
|
||||
// render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
//
|
||||
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
||||
// (pas seulement le premier niveau de l'écran), via leur
|
||||
// data-element-id — disponible sur chaque élément rendu, pas
|
||||
// seulement les éléments de premier niveau. Avant ce correctif,
|
||||
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
||||
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
||||
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
||||
// et se retrouvait régénéré en entier à sa place — potentiellement
|
||||
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine.
|
||||
//
|
||||
// Parmi les éléments marqués, on ne garde que les plus
|
||||
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
||||
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
||||
// son rendered_html contient celui de ses descendants — le
|
||||
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
||||
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
||||
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
||||
// gardait en pratique que l'ancêtre racine de l'écran et
|
||||
// annulait complètement l'effet recherché.)
|
||||
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
|
||||
));
|
||||
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;
|
||||
}
|
||||
}
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
||||
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
||||
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||
// tout de suite, sans attendre un aller-retour d'écran.
|
||||
runScreenShowTriggers(window.currentScreenId);
|
||||
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
|
||||
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
|
||||
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
|
||||
// comportement déjà correct pour ces deux-là). Repérer ce
|
||||
// commentaire permet de retrouver, à l'intérieur d'un fragment
|
||||
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
|
||||
// est niché dans un exemplaire d'élément de jeu réutilisable
|
||||
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
|
||||
// dialogue), où lui seul porte l'attribut data-element-id qui
|
||||
// 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 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
|
||||
);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
// commentaire marqueur à sa suite — sa balise porte
|
||||
// directement la classe "visibilityGated" à la place. Sans
|
||||
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
|
||||
// n'était jamais retrouvé ici (seul le sens caché->visible
|
||||
// l'était, via le commentaire) : son ancien nœud visible
|
||||
// restait donc affiché indéfiniment à côté du nouveau,
|
||||
// maintenant visible, qui le remplace normalement (ex. une
|
||||
// icône "enveloppe fermée" jamais masquée en même temps que
|
||||
// "enveloppe ouverte" qui apparaît après avoir cliqué).
|
||||
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
|
||||
if (hasCommentMarker || isHiddenPlaceholder) {
|
||||
found.push(node);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates
|
||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)))
|
||||
.forEach((el) => {
|
||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||
if (!target) return;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = el.rendered_html;
|
||||
const newNode = container.firstElementChild;
|
||||
if (!newNode) return;
|
||||
const innerMarked = findCommentMarkedDescendants(newNode);
|
||||
if (innerMarked.length) {
|
||||
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
|
||||
// Texte "Donnée liée" posé dans un élément de jeu
|
||||
// réutilisable) : on ne patche QUE ceux-là, en préservant
|
||||
// tout le reste — une boîte de dialogue ne doit ni
|
||||
// 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 liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
} else {
|
||||
target.replaceWith(newNode);
|
||||
}
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
} catch (err) {
|
||||
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
||||
}
|
||||
}
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
||||
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
||||
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||
// tout de suite, sans attendre un aller-retour d'écran.
|
||||
runScreenShowTriggers(window.currentScreenId);
|
||||
}
|
||||
|
||||
@@ -12,24 +12,26 @@
|
||||
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
||||
// branchement FORGE_OFFLINE nécessaire ici.
|
||||
|
||||
var activeCollisionRuleInterval = null;
|
||||
var activeCollisionRuleInteractListener = null;
|
||||
let activeCollisionRuleInterval = null;
|
||||
let activeCollisionRuleInteractListener = null;
|
||||
// État "actif" par (objectId, ruleIndex) — pour ne déclencher une action
|
||||
// qu'à l'ENTRÉE dans la collision/le périmètre, jamais à chaque tick tant
|
||||
// que le joueur y reste (le comportement attendu d'un déclencheur, pas
|
||||
// d'un effet répété 20 fois/seconde).
|
||||
var forgeCollisionRuleActiveState = {};
|
||||
let forgeCollisionRuleActiveState = {};
|
||||
|
||||
function forgeCollisionRuleStateKey(objectId, ruleIndex) {
|
||||
return objectId + ':' + ruleIndex;
|
||||
return `${objectId}:${ruleIndex}`;
|
||||
}
|
||||
|
||||
function forgeElementBoxRect(el, collision) {
|
||||
var left = parseFloat(el.style.left) || 0;
|
||||
var top = parseFloat(el.style.top) || 0;
|
||||
var width = parseFloat(el.style.width) || 0;
|
||||
var height = parseFloat(el.style.height) || 0;
|
||||
return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, 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;
|
||||
return forgeCollisionRectFromBox({
|
||||
left, top, width, height,
|
||||
}, collision);
|
||||
}
|
||||
|
||||
// Une marge (px) appliquée SEULEMENT à la détection du déclencheur
|
||||
@@ -49,13 +51,16 @@ function forgeElementBoxRect(el, collision) {
|
||||
// restant petit face à la taille d'un sprite (souvent 64-128px) pour ne
|
||||
// jamais déclencher "à la collision" alors que les objets sont encore
|
||||
// visiblement séparés.
|
||||
var FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
|
||||
const FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
|
||||
|
||||
function forgeExpandRect(rect, margin) {
|
||||
return {
|
||||
left: rect.left - margin, top: rect.top - margin,
|
||||
right: rect.right + margin, bottom: rect.bottom + margin,
|
||||
width: rect.width + margin * 2, height: rect.height + margin * 2,
|
||||
left: rect.left - margin,
|
||||
top: rect.top - margin,
|
||||
right: rect.right + margin,
|
||||
bottom: rect.bottom + margin,
|
||||
width: rect.width + margin * 2,
|
||||
height: rect.height + margin * 2,
|
||||
shape: rect.shape,
|
||||
};
|
||||
}
|
||||
@@ -77,7 +82,7 @@ function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
||||
// déjà position:relative — .sceneWorld/.sceneUI, voir static/style.css),
|
||||
// positionnée en JS aux mêmes coordonnées que l'objet.
|
||||
function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
|
||||
let bubble = parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionInteractBubble';
|
||||
@@ -97,18 +102,18 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
||||
// "c'est trop haut"). La boîte de collision, que l'auteur ajuste déjà
|
||||
// pour épouser la silhouette réelle, donne une ancre bien plus fidèle.
|
||||
function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
|
||||
var parentEl = objectEl.parentElement;
|
||||
const parentEl = objectEl.parentElement;
|
||||
if (!parentEl) return;
|
||||
var bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = 'Appuie sur ' + key;
|
||||
const bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = `Appuie sur ${key}`;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px'; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
|
||||
bubble.style.top = objRect.top + 'px'; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
|
||||
bubble.style.left = `${objRect.left + objRect.width / 2}px`; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
|
||||
bubble.style.top = `${objRect.top}px`; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
|
||||
}
|
||||
|
||||
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
var parentEl = objectEl && objectEl.parentElement;
|
||||
var bubble = parentEl && parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
|
||||
const parentEl = objectEl && objectEl.parentElement;
|
||||
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -118,11 +123,11 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
// collision dure" d'un déclencheur "interagir", peut être posée sur
|
||||
// N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas
|
||||
// nécessairement l'objet qui porte le déclencheur).
|
||||
var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
var forgeIndicationTimeouts = {};
|
||||
const FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
const forgeIndicationTimeouts = {};
|
||||
|
||||
function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]');
|
||||
let bubble = parentEl.querySelector(`.collisionIndicationBubble[data-for-object-id="${objectId}"]`);
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
@@ -133,17 +138,17 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
}
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
var targetEl = document.querySelector('[data-object-id="' + objectId + '"]');
|
||||
var parentEl = targetEl && targetEl.parentElement;
|
||||
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
||||
const parentEl = targetEl && targetEl.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
var objRect = forgeElementBoxRect(targetEl);
|
||||
var bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
const objRect = forgeElementBoxRect(targetEl);
|
||||
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = texte;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px';
|
||||
bubble.style.top = objRect.top + 'px';
|
||||
bubble.style.left = `${objRect.left + objRect.width / 2}px`;
|
||||
bubble.style.top = `${objRect.top}px`;
|
||||
if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]);
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(function () {
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(() => {
|
||||
bubble.style.display = 'none';
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
@@ -168,8 +173,8 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
// 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é.
|
||||
var lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
var dialogueId = action.dialogue && action.dialogue.id;
|
||||
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
|
||||
@@ -187,30 +192,30 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
// 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).
|
||||
var isTrue = evaluateConditionClause({
|
||||
const isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
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.
|
||||
var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]');
|
||||
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(function () {});
|
||||
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, function () {
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
@@ -221,37 +226,37 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
// 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.
|
||||
var seconds = parseFloat(action.data_value);
|
||||
var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(function () {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeCollisionRuleTick(screenData) {
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||
if (!player || !playerEl) return;
|
||||
var playerRect = forgeElementBoxRect(playerEl, player.collision);
|
||||
const playerRect = forgeElementBoxRect(playerEl, player.collision);
|
||||
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
var objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||
|
||||
var anyInteractActive = false;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
let anyInteractActive = false;
|
||||
rules.forEach((rule, ruleIndex) => {
|
||||
// "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules,
|
||||
// écouteurs DOM directs) — jamais évalués ici, sans quoi ils
|
||||
// s'exécuteraient EN PLUS, à la proximité du joueur, en plus de
|
||||
// leur propre déclencheur.
|
||||
if (rule.trigger !== 'collision') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
const isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
const wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
|
||||
if (rule.action.type === 'interagir') {
|
||||
if (isActive) {
|
||||
@@ -274,16 +279,16 @@ function forgeCollisionRuleTick(screenData) {
|
||||
// qu'elle reste enfoncée.
|
||||
function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
activeCollisionRuleInteractListener = function (ev) {
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
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;
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
rules.forEach((rule, ruleIndex) => {
|
||||
if (rule.action.type !== 'interagir') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
if (!forgeCollisionRuleActiveState[stateKey]) return;
|
||||
forgeRunCollisionRuleAction(rule.action.sub_action, targetEl, obj);
|
||||
});
|
||||
@@ -298,26 +303,26 @@ function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
// posé une fois par objet suffit (voir screens/rendering/
|
||||
// collision_rules.py). Re-attachés à chaque affichage d'écran, comme le
|
||||
// reste de ce fichier.
|
||||
var activeClicSurvolListeners = [];
|
||||
let activeClicSurvolListeners = [];
|
||||
|
||||
function forgeUnbindClicSurvolRules() {
|
||||
activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners.forEach((l) => { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners = [];
|
||||
}
|
||||
|
||||
function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule) {
|
||||
rules.forEach((rule) => {
|
||||
if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return;
|
||||
var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
const eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
const handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
targetEl.addEventListener(eventName, handler);
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler });
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -343,22 +348,29 @@ function forgeStopCollisionRuleControllers() {
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
const elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; });
|
||||
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasRules = elements.some((o) => o.collision_rules && o.collision_rules.length);
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
activeCollisionRuleInterval = setInterval(function () { forgeCollisionRuleTick(screenData); }, 50);
|
||||
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
||||
forgeStartCollisionRuleInteractListener(screenData);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que personnage-controller.js.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble,
|
||||
forgeCollisionRuleTriggerActive,
|
||||
forgeRunCollisionRuleAction,
|
||||
forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers,
|
||||
forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey,
|
||||
forgeElementBoxRect,
|
||||
forgeExpandRect,
|
||||
forgeBindClicSurvolRules,
|
||||
forgeUnbindClicSurvolRules,
|
||||
forgeShowIndicationBubble,
|
||||
};
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
|
||||
@@ -19,7 +19,7 @@ function readFieldValue(definitionId, rowId, fieldName) {
|
||||
// capturé (voir bindClicks() dans triggers.js).
|
||||
if (rowId === -1) rowId = window.lastClickedRowId;
|
||||
const rows = gameData.data[String(definitionId)] || [];
|
||||
const row = rows.find(r => r.id === rowId);
|
||||
const row = rows.find((r) => r.id === rowId);
|
||||
return row ? row[fieldName] : undefined;
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
const e = normalizeBool(expected);
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual), en = parseFloat(expected);
|
||||
const numeric = !isNaN(an) && !isNaN(en);
|
||||
const an = parseFloat(actual); const
|
||||
en = parseFloat(expected);
|
||||
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
||||
if (numeric) {
|
||||
switch (operator) {
|
||||
case 'egal': return an === en;
|
||||
@@ -53,6 +54,7 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
case 'inferieur': return an < en;
|
||||
case 'superieur_egal': return an >= en;
|
||||
case 'inferieur_egal': return an <= en;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
||||
@@ -63,7 +65,8 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
|
||||
// superieur/inferieur restent une comparaison lexicographique brute
|
||||
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
|
||||
const asLower = as.toLowerCase(), esLower = es.toLowerCase();
|
||||
const asLower = as.toLowerCase(); const
|
||||
esLower = es.toLowerCase();
|
||||
switch (operator) {
|
||||
case 'egal': return asLower === esLower;
|
||||
case 'different': return asLower !== esLower;
|
||||
@@ -98,7 +101,7 @@ function resolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
let current;
|
||||
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
|
||||
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
|
||||
const segmentRe = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
@@ -133,7 +136,9 @@ function forgeCollisionRectFromBox(box, collision) {
|
||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
||||
const shape = (collision && collision.shape) || 'rectangle';
|
||||
return { left, top, right: left + width, bottom: top + height, width, height, shape };
|
||||
return {
|
||||
left, top, right: left + width, bottom: top + height, width, height, shape,
|
||||
};
|
||||
}
|
||||
|
||||
function forgeRectanglesOverlap(a, b) {
|
||||
@@ -145,9 +150,12 @@ function forgeRectanglesOverlap(a, b) {
|
||||
// volontairement approximatif plutôt qu'une vraie intersection cercle-
|
||||
// rectangle, hors scope de ce premier réglage).
|
||||
function forgeCirclesOverlap(a, b) {
|
||||
const acx = a.left + a.width / 2, acy = a.top + a.height / 2, ar = Math.min(a.width, a.height) / 2;
|
||||
const bcx = b.left + b.width / 2, bcy = b.top + b.height / 2, br = Math.min(b.width, b.height) / 2;
|
||||
const dx = acx - bcx, dy = acy - bcy;
|
||||
const acx = a.left + a.width / 2; const acy = a.top + a.height / 2; const
|
||||
ar = Math.min(a.width, a.height) / 2;
|
||||
const bcx = b.left + b.width / 2; const bcy = b.top + b.height / 2; const
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -174,14 +182,17 @@ function forgeShapesOverlap(a, b) {
|
||||
// nulles), ou collision explicitement désactivée => pas de chevauchement
|
||||
// plutôt que de planter.
|
||||
function elementsOverlap(elementIdA, elementIdB) {
|
||||
const a = document.querySelector('[data-element-id="' + elementIdA + '"]');
|
||||
const b = document.querySelector('[data-element-id="' + elementIdB + '"]');
|
||||
const a = document.querySelector(`[data-element-id="${elementIdA}"]`);
|
||||
const b = document.querySelector(`[data-element-id="${elementIdB}"]`);
|
||||
if (!a || !b) return false;
|
||||
const screenData = (typeof screensData !== 'undefined' && typeof window !== 'undefined')
|
||||
&& screensData.find(function (s) { return s.id === window.currentScreenId; });
|
||||
const objA = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdA; });
|
||||
const objB = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdB; });
|
||||
if ((objA && objA.collision && objA.collision.enabled === false) || (objB && objB.collision && objB.collision.enabled === false)) {
|
||||
&& screensData.find((s) => s.id === window.currentScreenId);
|
||||
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)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
||||
@@ -209,7 +220,11 @@ function evaluateConditionClause(clause) {
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
|
||||
const actual = readFieldValue(
|
||||
clause.definition_id ?? clause.cond_definition_id,
|
||||
clause.row_id ?? clause.cond_row_id,
|
||||
clause.field ?? clause.cond_field,
|
||||
);
|
||||
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
|
||||
}
|
||||
|
||||
@@ -230,7 +245,15 @@ function evaluateConditionNode(node) {
|
||||
// cette branche ne s'exécute jamais côté client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode,
|
||||
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, forgeShapesOverlap,
|
||||
readFieldValue,
|
||||
compareValues,
|
||||
readVariableValue,
|
||||
resolveVariablePath,
|
||||
evaluateConditionClause,
|
||||
evaluateConditionNode,
|
||||
forgeCollisionRectFromBox,
|
||||
forgeRectanglesOverlap,
|
||||
forgeCirclesOverlap,
|
||||
forgeShapesOverlap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||
// l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
const forgeDialogueBoxState = {};
|
||||
const forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||
// uniquement à calculer un taux de réussite global (voir
|
||||
@@ -56,17 +56,18 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------- Boîte de dialogue ----------
|
||||
|
||||
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
||||
return document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .dialogueBoxWidget`);
|
||||
}
|
||||
|
||||
function forgeDialogueBoxWidgetById(objectId) {
|
||||
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
|
||||
return document.querySelector(`.dialogueBoxWidget[data-object-id="${objectId}"]`);
|
||||
}
|
||||
|
||||
function forgeDialogueBoxFooterEl(widget) {
|
||||
@@ -78,7 +79,7 @@ function forgeDialogueBoxFooterEl(widget) {
|
||||
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
|
||||
// sa propre voix off) prend sa place, pour ne jamais superposer deux
|
||||
// voix off en même temps si le joueur avance vite dans la conversation.
|
||||
var forgeDialogueLineAudio = null;
|
||||
let forgeDialogueLineAudio = null;
|
||||
|
||||
function forgeStopDialogueLineAudio() {
|
||||
if (forgeDialogueLineAudio) {
|
||||
@@ -88,14 +89,16 @@ function forgeStopDialogueLineAudio() {
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
const header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
const body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
if (header) header.textContent = line.speaker;
|
||||
if (body) body.textContent = line.text;
|
||||
forgeStopDialogueLineAudio();
|
||||
if (line.audio_url) {
|
||||
forgeDialogueLineAudio = new Audio(line.audio_url);
|
||||
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
|
||||
// lecture bloquée par le navigateur (pas d'interaction récente) : la
|
||||
// bulle reste lisible sans voix off plutôt que de planter.
|
||||
forgeDialogueLineAudio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,24 +106,24 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||
// affichée (voir forgeShowCurrentConversationLine).
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
const footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = `<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(${objectId})">Suivant →</button>`;
|
||||
}
|
||||
|
||||
// ---------- Boîte à quiz ----------
|
||||
|
||||
function forgeQuizBoxWidgetOnCurrentScreen() {
|
||||
var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||
const scoped = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .quizBoxWidget`);
|
||||
if (scoped) return scoped;
|
||||
// Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir
|
||||
// forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus
|
||||
// une fois la première question affichée — retrouvé via l'attribut
|
||||
// posé au déplacement plutôt que par sa position dans le DOM.
|
||||
return document.querySelector('.quizBoxWidget[data-owner-screen-id="' + window.currentScreenId + '"]');
|
||||
return document.querySelector(`.quizBoxWidget[data-owner-screen-id="${window.currentScreenId}"]`);
|
||||
}
|
||||
|
||||
function forgeQuizBoxWidgetById(objectId) {
|
||||
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
|
||||
return document.querySelector(`.quizBoxWidget[data-object-id="${objectId}"]`);
|
||||
}
|
||||
|
||||
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
|
||||
@@ -137,7 +140,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
||||
// dessus de le retrouver malgré le déplacement.
|
||||
function forgeEscapeCameraForFullscreen(widget) {
|
||||
if (widget.parentNode === document.body) return;
|
||||
var screenEl = widget.closest('.playScreen');
|
||||
const screenEl = widget.closest('.playScreen');
|
||||
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
|
||||
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||
document.body.appendChild(widget);
|
||||
@@ -149,10 +152,10 @@ function forgeEscapeCameraForFullscreen(widget) {
|
||||
// n'est jamais laissé actif par erreur, quel que soit le chemin de
|
||||
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
|
||||
function forgeHideQuizBoxOnCurrentScreen() {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
widget.classList.remove('is-active', 'is-fullscreen');
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
const objectId = widget.getAttribute('data-object-id');
|
||||
forgeStopQuizTimer(objectId);
|
||||
}
|
||||
|
||||
@@ -164,10 +167,10 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
||||
// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de
|
||||
// payload séparé — cohérent avec le reste de ce moteur (data-* posés au
|
||||
// rendu, jamais un aller-retour serveur pour du pur affichage).
|
||||
var forgeQuizTimerState = {};
|
||||
const forgeQuizTimerState = {};
|
||||
|
||||
function forgeStopQuizTimer(objectId) {
|
||||
var t = forgeQuizTimerState[objectId];
|
||||
const t = forgeQuizTimerState[objectId];
|
||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
||||
delete forgeQuizTimerState[objectId];
|
||||
}
|
||||
@@ -178,18 +181,19 @@ function forgeStopQuizTimer(objectId) {
|
||||
// d'une question à l'autre sans se réinitialiser.
|
||||
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
forgeStopQuizTimer(objectId);
|
||||
var state = { remaining: seconds };
|
||||
const state = { remaining: seconds };
|
||||
forgeQuizTimerState[objectId] = state;
|
||||
var timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||||
const timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||||
function render() {
|
||||
if (!timerEl) return;
|
||||
var remaining = Math.max(0, state.remaining);
|
||||
var m = Math.floor(remaining / 60), s = remaining % 60;
|
||||
timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
const remaining = Math.max(0, state.remaining);
|
||||
const m = Math.floor(remaining / 60); const
|
||||
s = remaining % 60;
|
||||
timerEl.textContent = `⏱ ${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
timerEl.classList.toggle('is-urgent', remaining <= 5);
|
||||
}
|
||||
render();
|
||||
state.intervalId = setInterval(function () {
|
||||
state.intervalId = setInterval(() => {
|
||||
state.remaining -= 1;
|
||||
render();
|
||||
if (state.remaining <= 0) {
|
||||
@@ -202,8 +206,8 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
// ---------- Score ----------
|
||||
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
}
|
||||
|
||||
@@ -213,8 +217,8 @@ function forgeUpdateAllScoreWidgets() {
|
||||
// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz
|
||||
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
||||
function forgeUpdateQuizBoxScoreDisplays() {
|
||||
var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = 'Score : ' + window.forgeQuizScore;
|
||||
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
|
||||
}
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
@@ -237,7 +241,7 @@ function forgeSyncQuizScoreToScorm() {
|
||||
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||
// SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||||
@@ -264,21 +268,21 @@ function forgeSyncDialogueStartedToScorm() {
|
||||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
function forgeComputeFinalScormStatus() {
|
||||
var total = window.forgeQuizTotalAnswered || 0;
|
||||
const total = window.forgeQuizTotalAnswered || 0;
|
||||
if (total === 0) return 'termine';
|
||||
var percent = (window.forgeQuizCorrectAnswered / total) * 100;
|
||||
var threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const percent = (window.forgeQuizCorrectAnswered / total) * 100;
|
||||
const threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
return percent >= threshold ? 'reussi' : 'echoue';
|
||||
}
|
||||
|
||||
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
|
||||
const completableIds = (gameData.completable_dialogue_ids || []);
|
||||
const allDone = completableIds.length > 0 && completableIds.every((id) => !!window.forgeCompletedDialogueIds[id]);
|
||||
if (!allDone) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
var finalStatus = forgeComputeFinalScormStatus();
|
||||
const finalStatus = forgeComputeFinalScormStatus();
|
||||
gameData.scoring.status = finalStatus;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
|
||||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(finalStatus);
|
||||
}
|
||||
|
||||
function forgeAddQuizScore(amount) {
|
||||
@@ -295,10 +299,10 @@ function forgeAddQuizScore(amount) {
|
||||
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
|
||||
// n'est posée : impossible de bloquer la conversation indéfiniment).
|
||||
function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
var line = state.lines[state.index];
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
const line = state.lines[state.index];
|
||||
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
|
||||
@@ -336,8 +340,8 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// sans boîte de dialogue posée reste ignorée plus loin (voir
|
||||
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
||||
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId);
|
||||
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
@@ -347,8 +351,12 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[stateKey] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
dialogueId,
|
||||
objectName,
|
||||
lines,
|
||||
index: 0,
|
||||
markCompleted: !!markCompleted,
|
||||
thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(stateKey);
|
||||
}
|
||||
@@ -358,7 +366,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
|
||||
function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
window.forgeCompletedDialogueIds[dialogueId] = true;
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === 'function') forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
}
|
||||
|
||||
@@ -370,13 +378,13 @@ function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||||
// dialogue est fini", jamais avant.
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeStopDialogueLineAudio();
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
@@ -391,7 +399,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
|
||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||
function forgeDialogueBoxAdvance(objectId) {
|
||||
var state = forgeDialogueBoxState[objectId];
|
||||
const state = forgeDialogueBoxState[objectId];
|
||||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||||
forgeAdvanceConversation(objectId);
|
||||
}
|
||||
@@ -403,13 +411,19 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||||
// réponse qu'aucun widget ne peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var previousState = forgeQuizBoxState[quizObjectId];
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const previousState = forgeQuizBoxState[quizObjectId];
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
|
||||
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
|
||||
dialogueObjectId,
|
||||
correctIndex: line.correct_index,
|
||||
rewardAmount: line.reward_amount,
|
||||
resolved: false,
|
||||
dialogueId,
|
||||
objectName,
|
||||
questionIndex,
|
||||
questionText: line.text,
|
||||
// Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) :
|
||||
// ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite
|
||||
// sans se réinitialiser — ce drapeau survit d'une question à l'autre
|
||||
@@ -423,40 +437,38 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
correctCount: previousState ? previousState.correctCount : 0,
|
||||
answeredCount: previousState ? previousState.answeredCount : 0,
|
||||
};
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
const questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
const choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (header) header.textContent = objectName || '';
|
||||
if (questionEl) questionEl.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
||||
}).join('');
|
||||
choicesEl.innerHTML = line.choices.map((choice, i) => `<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(${quizObjectId}, ${i})">${forgeEscapeHtml(choice)}</button>`).join('');
|
||||
// "Continuer →" TOUJOURS présent dès la question (demande explicite :
|
||||
// "le bouton continuer est toujours visible mais activé seulement
|
||||
// quand un choix est fait", comme la maquette manga validée) —
|
||||
// désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais
|
||||
// recréé à ce moment-là, juste .disabled=false, voir plus bas).
|
||||
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')" disabled>Continuer →</button>';
|
||||
choicesEl.innerHTML += `<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(${quizObjectId})" disabled>Continuer →</button>`;
|
||||
}
|
||||
// "Plein écran" (demande explicite) — reposé à CHAQUE question,
|
||||
// sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen,
|
||||
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
||||
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
||||
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
||||
var isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
||||
const isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
||||
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
||||
widget.classList.toggle('is-fullscreen', isFullscreen);
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
||||
// imposé, voir screens/rendering/quiz_box_config.py).
|
||||
var timerMode = widget.getAttribute('data-timer-mode');
|
||||
var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
const timerMode = widget.getAttribute('data-timer-mode');
|
||||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
if (timerMode === 'question') {
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
} else if (timerMode !== 'quiz') {
|
||||
forgeStopQuizTimer(quizObjectId);
|
||||
}
|
||||
@@ -469,7 +481,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
widget.classList.add('is-active');
|
||||
// Effet d'entrée propre au modèle (voir static/js/play/
|
||||
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
||||
var showFx = forgeQuizTemplateEffectsFor(widget).onShow;
|
||||
const showFx = forgeQuizTemplateEffectsFor(widget).onShow;
|
||||
if (showFx) showFx(widget);
|
||||
}
|
||||
|
||||
@@ -477,7 +489,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
||||
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
||||
function forgeQuizTemplateEffectsFor(widget) {
|
||||
var template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||
}
|
||||
|
||||
@@ -491,11 +503,11 @@ function forgeQuizTemplateEffectsFor(widget) {
|
||||
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
|
||||
// second clic pendant cet affichage est ignoré (`resolved`).
|
||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
state.resolved = true;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var correct = choiceIndex === state.correctIndex;
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const correct = choiceIndex === state.correctIndex;
|
||||
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
||||
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
||||
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
||||
@@ -503,8 +515,8 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// d'une question à l'autre.
|
||||
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
|
||||
if (widget) {
|
||||
var buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].disabled = true;
|
||||
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
|
||||
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
|
||||
@@ -512,7 +524,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
}
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
var fx = forgeQuizTemplateEffectsFor(widget);
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
if (correct) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
state.correctCount += 1;
|
||||
@@ -521,13 +533,28 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
} else if (fx.onWrong) {
|
||||
fx.onWrong(widget);
|
||||
}
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||
forgeXapiNotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
state.objectName,
|
||||
state.questionIndex,
|
||||
state.questionText,
|
||||
correct,
|
||||
correct ? state.rewardAmount : 0,
|
||||
);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
||||
forgeScorm2004NotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
state.objectName,
|
||||
state.questionIndex,
|
||||
state.questionText,
|
||||
correct,
|
||||
choiceIndex,
|
||||
state.correctIndex,
|
||||
);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
@@ -539,7 +566,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
||||
// choix juste au-dessus).
|
||||
if (widget) {
|
||||
var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -558,18 +585,18 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// les lignes.
|
||||
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
if (!widget) return null;
|
||||
var dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
const dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
if (!dialogueState) return null;
|
||||
var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
const isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
if (!isEnding) return null;
|
||||
var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
const completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
if (!completeFx) return null;
|
||||
var correctCount = state.correctCount;
|
||||
var answeredCount = state.answeredCount;
|
||||
const { correctCount } = state;
|
||||
const { answeredCount } = state;
|
||||
return function () {
|
||||
widget.classList.add('is-active');
|
||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
||||
completeFx(widget, { correctCount: correctCount, totalCount: answeredCount });
|
||||
completeFx(widget, { correctCount, totalCount: answeredCount });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -593,10 +620,10 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
|
||||
// courant en pratique.
|
||||
function forgeQuizResultQuit(quizObjectId) {
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
if (widget) {
|
||||
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
|
||||
@@ -608,10 +635,10 @@ function forgeQuizResultQuit(quizObjectId) {
|
||||
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
|
||||
// automatique (voir le commentaire ci-dessus).
|
||||
function forgeQuizBoxContinue(quizObjectId) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
@@ -629,31 +656,31 @@ function forgeQuizBoxContinue(quizObjectId) {
|
||||
// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse
|
||||
// sans en marquer une "fausse"), jamais de code dupliqué.
|
||||
function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
forgeQuizBoxAnswer(quizObjectId, -1);
|
||||
if (!wholeQuizExpired) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
// Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER
|
||||
// est écoulé — désactivé/relibellé pour éviter toute course avec la
|
||||
// fin automatique programmée juste en dessous.
|
||||
if (widget) {
|
||||
var continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||||
const continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
|
||||
}
|
||||
setTimeout(function () {
|
||||
var s = forgeQuizBoxState[quizObjectId];
|
||||
setTimeout(() => {
|
||||
const s = forgeQuizBoxState[quizObjectId];
|
||||
if (!s) return; // déjà fermé entre-temps
|
||||
// forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non
|
||||
// des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT
|
||||
// que forgeAdvanceConversation ne supprime forgeDialogueBoxState.
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
|
||||
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
// Force l'épuisement des lignes restantes — même sortie que la fin
|
||||
// naturelle d'un dialogue (markCompleted/thenAction inclus, voir
|
||||
// forgeAdvanceConversation), sans dupliquer cette logique.
|
||||
var dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
|
||||
const dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
|
||||
if (dialogueState) {
|
||||
dialogueState.index = dialogueState.lines.length;
|
||||
forgeAdvanceConversation(s.dialogueObjectId);
|
||||
@@ -666,17 +693,34 @@ function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
|
||||
// contrôleurs de ce dossier.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowDialogueBox,
|
||||
forgeDialogueBoxAdvance,
|
||||
forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen,
|
||||
forgeDialogueBoxWidgetById,
|
||||
forgeRenderDialogueBoxLine,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore,
|
||||
forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeShowQuizBox,
|
||||
forgeQuizBoxAnswer,
|
||||
forgeQuizBoxContinue,
|
||||
forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen,
|
||||
forgeQuizBoxWidgetById,
|
||||
forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets,
|
||||
forgeUpdateQuizBoxScoreDisplays,
|
||||
forgeAddQuizScore,
|
||||
forgeAdvanceConversation,
|
||||
forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm,
|
||||
forgeSyncDialogueStartedToScorm,
|
||||
forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState,
|
||||
forgeStartQuizTimer,
|
||||
forgeStopQuizTimer,
|
||||
forgeQuizTimerState,
|
||||
forgeEscapeCameraForFullscreen,
|
||||
forgeQuizPrepareResultScreen, forgeQuizResultQuit,
|
||||
forgeQuizPrepareResultScreen,
|
||||
forgeQuizResultQuit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,23 +23,23 @@ function runFlowFrom(nodeId, flowScreenId) {
|
||||
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
|
||||
if (!flow) return;
|
||||
const nodesById = {};
|
||||
flow.nodes.forEach(n => { nodesById[n.id] = n; });
|
||||
flow.nodes.forEach((n) => { nodesById[n.id] = n; });
|
||||
|
||||
function nextEdges(fromId, port) {
|
||||
return flow.edges.filter(e => e.from_node_id === fromId && e.from_port === port);
|
||||
return flow.edges.filter((e) => e.from_node_id === fromId && e.from_port === port);
|
||||
}
|
||||
|
||||
function step(currentId) {
|
||||
const node = nodesById[currentId];
|
||||
if (!node) return;
|
||||
if (node.node_type === 'trigger') {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
nextEdges(node.id, 'out').forEach((edge) => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'condition') {
|
||||
const port = evaluateConditionNode(node) ? 'vrai' : 'faux';
|
||||
nextEdges(node.id, port).forEach(edge => step(edge.to_node_id));
|
||||
nextEdges(node.id, port).forEach((edge) => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'action') {
|
||||
runActionNode(node).then(() => {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
nextEdges(node.id, 'out').forEach((edge) => step(edge.to_node_id));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,11 @@ function runFlowFrom(nodeId, flowScreenId) {
|
||||
// C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
|
||||
// suite condition/action, avec ses réglages habituels — cible fixe,
|
||||
// "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
|
||||
window.dispatchGameEvent = function(eventId) {
|
||||
window.dispatchGameEvent = function (eventId) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
const flow = gameData.flows[screenIdKey];
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
|
||||
.forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId)
|
||||
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,33 +8,49 @@ require('../../conditions.js');
|
||||
global.compareValues = require('../../conditions.js').compareValues;
|
||||
|
||||
const filterMod = require('../filter-repeater-rows.js');
|
||||
|
||||
Object.assign(global, filterMod);
|
||||
|
||||
const visibilityMod = require('../visibility-condition.js');
|
||||
|
||||
Object.assign(global, visibilityMod);
|
||||
|
||||
const computeMod = require('../compute-operation.js');
|
||||
|
||||
Object.assign(global, computeMod);
|
||||
|
||||
const applyMod = require('../apply-actions.js');
|
||||
|
||||
Object.assign(global, applyMod);
|
||||
|
||||
const renderElementMod = require('../render-element.js');
|
||||
|
||||
Object.assign(global, renderElementMod);
|
||||
|
||||
const jaugeMod = require('../render-jauge.js');
|
||||
|
||||
Object.assign(global, jaugeMod);
|
||||
|
||||
const overlayMod = require('../render-overlay.js');
|
||||
|
||||
Object.assign(global, overlayMod);
|
||||
|
||||
const repeaterMod = require('../render-repeater.js');
|
||||
|
||||
Object.assign(global, repeaterMod);
|
||||
|
||||
const specialMod = require('../render-special-widgets.js');
|
||||
|
||||
Object.assign(global, specialMod);
|
||||
|
||||
module.exports = Object.assign(
|
||||
{}, filterMod, visibilityMod, computeMod, applyMod, renderElementMod,
|
||||
jaugeMod, overlayMod, repeaterMod, specialMod
|
||||
);
|
||||
module.exports = {
|
||||
...filterMod,
|
||||
...visibilityMod,
|
||||
...computeMod,
|
||||
...applyMod,
|
||||
...renderElementMod,
|
||||
...jaugeMod,
|
||||
...overlayMod,
|
||||
...repeaterMod,
|
||||
...specialMod,
|
||||
};
|
||||
|
||||
@@ -30,12 +30,14 @@ function withOfflineFetchGuard(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
|
||||
test('modifier_variable — mute gameData en mémoire sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { variables: { score_var: { value: '10', type: 'nombre_entier' } } };
|
||||
const node = {
|
||||
action_type: 'modifier_variable',
|
||||
target_variable: 'score_var', data_operation: 'incrementer', data_value: '5',
|
||||
target_variable: 'score_var',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '5',
|
||||
};
|
||||
runActionNode(node);
|
||||
assert.equal(global.gameData.variables.score_var.value, '15');
|
||||
@@ -43,7 +45,7 @@ test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("modifier_score — mute gameData.scoring sans fetch()", () => {
|
||||
test('modifier_score — mute gameData.scoring sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
|
||||
const node = { action_type: 'modifier_score', data_operation: 'incrementer', data_value: '3' };
|
||||
@@ -53,7 +55,7 @@ test("modifier_score — mute gameData.scoring sans fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () => {
|
||||
test('definir_statut_partie — mute gameData.scoring.status sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
|
||||
const node = { action_type: 'definir_statut_partie', data_value: 'reussi' };
|
||||
@@ -63,9 +65,9 @@ test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () =
|
||||
});
|
||||
});
|
||||
|
||||
test("ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()", () => {
|
||||
test('ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { data: { '1': [{ id: 5 }] }, definition_names: {}, fields_meta: { '1': [] } };
|
||||
global.gameData = { data: { 1: [{ id: 5 }] }, definition_names: {}, fields_meta: { 1: [] } };
|
||||
const node = { action_type: 'ajouter_ligne', target_definition_id: 1 };
|
||||
runActionNode(node);
|
||||
assert.equal(global.window.lastInsertedRowId, 6);
|
||||
|
||||
@@ -18,12 +18,16 @@ function baseGameData() {
|
||||
},
|
||||
scoring: { score: 0, status: 'non_commence' },
|
||||
data: {
|
||||
'1': [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
|
||||
1: [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
|
||||
},
|
||||
fields_meta: {
|
||||
'1': [
|
||||
{ name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10 },
|
||||
{ name: 'nom', type: 'texte', min_value: null, max_value: null },
|
||||
1: [
|
||||
{
|
||||
name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10,
|
||||
},
|
||||
{
|
||||
name: 'nom', type: 'texte', min_value: null, max_value: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -65,21 +69,30 @@ test('definir_statut_partie — rejette un statut inconnu', () => {
|
||||
test('modifier_donnee — bornage min/max comme apply_data_action.py', () => {
|
||||
const gd = baseGameData();
|
||||
forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 1, target_field: 'points',
|
||||
data_operation: 'incrementer', data_value: '100',
|
||||
target_definition_id: 1,
|
||||
target_row_id: 1,
|
||||
target_field: 'points',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '100',
|
||||
});
|
||||
assert.equal(gd.data['1'][0].points, 10); // borné à max_value=10, pas 105
|
||||
});
|
||||
|
||||
test('modifier_donnee — ligne/champ/objet inconnu échoue proprement', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1' }), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1' }), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1',
|
||||
}), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('ajouter_ligne — ligne vide, id auto-incrémenté, booléen à 0', () => {
|
||||
const gd = baseGameData();
|
||||
gd.fields_meta['1'].push({ name: 'actif', type: 'booleen', min_value: null, max_value: null });
|
||||
gd.fields_meta['1'].push({
|
||||
name: 'actif', type: 'booleen', min_value: null, max_value: null,
|
||||
});
|
||||
const newId = forgeApplyAddRowActionOffline(gd, { target_definition_id: 1 });
|
||||
assert.equal(newId, 3); // max existant (2) + 1
|
||||
const row = gd.data['1'][2];
|
||||
|
||||
@@ -15,18 +15,18 @@ function baseGameData() {
|
||||
niveau: { value: '3', type: 'nombre_entier' },
|
||||
perso: { value: JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }), type: 'objet' },
|
||||
},
|
||||
definition_names: { '1': 'Progression', '2': 'Ennemis' },
|
||||
definition_names: { 1: 'Progression', 2: 'Ennemis' },
|
||||
fields_meta: {
|
||||
// "boss" (relation) : gameData.data (voir full_game_payload.py) est
|
||||
// déjà indexé par le nom D'AFFICHAGE du champ ("boss"), JAMAIS par sa
|
||||
// colonne SQL réelle ("boss_id") — contrairement à côté serveur
|
||||
// (filter_repeater_rows.py::_field_column), qui lit la base fraîche.
|
||||
'1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
|
||||
'2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
|
||||
1: [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
|
||||
2: [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
|
||||
},
|
||||
data: {
|
||||
'1': [{ id: 1, niveau_actuel: 4, boss: 7 }],
|
||||
'2': [
|
||||
1: [{ id: 1, niveau_actuel: 4, boss: 7 }],
|
||||
2: [
|
||||
{ id: 1, nom: 'Slime', pv: 10 },
|
||||
{ id: 2, nom: 'Dragon', pv: 100 },
|
||||
],
|
||||
@@ -85,20 +85,20 @@ test('forgeFilterRowsByClauses — ET (toutes vraies) vs OU (au moins une)', ()
|
||||
{ champ: 'nom', operateur: 'egal', valeur: 'Dragon' },
|
||||
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
|
||||
];
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map(r => r.id), [2]);
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map((r) => r.id), [2]);
|
||||
|
||||
const orClauses = [
|
||||
{ champ: 'nom', operateur: 'egal', valeur: 'Slime' },
|
||||
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
|
||||
];
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map(r => r.id), [1, 2]);
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map((r) => r.id), [1, 2]);
|
||||
});
|
||||
|
||||
test('forgeFilterRepeaterRows — ancien format 2 filtres fixes (repli)', () => {
|
||||
const gd = baseGameData();
|
||||
const attrs = { _filtre_champ: 'nom', _filtre_operateur: 'egal', _filtre_valeur: 'Slime' };
|
||||
const result = forgeFilterRepeaterRows(gd, '2', gd.data['2'], attrs);
|
||||
assert.deepEqual(result.map(r => r.id), [1]);
|
||||
assert.deepEqual(result.map((r) => r.id), [1]);
|
||||
});
|
||||
|
||||
test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', () => {
|
||||
|
||||
@@ -17,16 +17,26 @@ function baseGameData() {
|
||||
element_type_templates: {},
|
||||
screens: [
|
||||
{
|
||||
id: 1, kind: 'document', preview_aspect: '9/16',
|
||||
id: 1,
|
||||
kind: 'document',
|
||||
preview_aspect: '9/16',
|
||||
elements: [
|
||||
{
|
||||
id: 10, widget: 'texte', content: 'Score : x', attributes: {}, style: {}, parent_id: null,
|
||||
id: 10,
|
||||
widget: 'texte',
|
||||
content: 'Score : x',
|
||||
attributes: {},
|
||||
style: {},
|
||||
parent_id: null,
|
||||
rendered_html: '<p>ancien rendu</p>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, kind: 'jeu_2d', scene_width: 960, scene_height: 540,
|
||||
id: 2,
|
||||
kind: 'jeu_2d',
|
||||
scene_width: 960,
|
||||
scene_height: 540,
|
||||
elements: [{ id: 20, kind: 'decor', rendered_html: '<img data-object-id="20">' }],
|
||||
},
|
||||
],
|
||||
@@ -40,7 +50,7 @@ test('recalcule rendered_html pour un écran document', () => {
|
||||
assert.ok(gd.screens[0].elements[0].rendered_html.startsWith('<p '));
|
||||
});
|
||||
|
||||
test("ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)", () => {
|
||||
test('ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)', () => {
|
||||
const gd = baseGameData();
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
assert.equal(gd.screens[1].elements[0].rendered_html, '<img data-object-id="20">');
|
||||
@@ -53,7 +63,7 @@ test('un élément imbriqué reçoit aussi son PROPRE rendered_html (pas seuleme
|
||||
});
|
||||
gd.screens[0].elements[0].widget = 'conteneur'; // devient le parent
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
const child = gd.screens[0].elements.find(e => e.id === 11);
|
||||
const child = gd.screens[0].elements.find((e) => e.id === 11);
|
||||
assert.ok(child.rendered_html.includes('enfant'));
|
||||
});
|
||||
|
||||
@@ -62,8 +72,10 @@ test('reflète une mutation déjà appliquée (score/variable) via la Donnée li
|
||||
gd.variables.niveau = { value: '5', type: 'nombre_entier' };
|
||||
gd.screens[0].elements[0].content = 'Visible';
|
||||
gd.screens[0].elements[0].attributes = {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '5',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal',
|
||||
_visibilite_cond_valeur: '5',
|
||||
};
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
assert.ok(gd.screens[0].elements[0].rendered_html.includes('Visible'));
|
||||
|
||||
@@ -9,11 +9,15 @@ const assert = require('node:assert/strict');
|
||||
const { forgeRenderElementHtml, forgeStyleString, forgeAttrString } = require('../render-element.js');
|
||||
|
||||
function baseGameData() {
|
||||
return { variables: {}, definition_names: {}, fields_meta: {}, data: {} };
|
||||
return {
|
||||
variables: {}, definition_names: {}, fields_meta: {}, data: {},
|
||||
};
|
||||
}
|
||||
|
||||
test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
|
||||
const el = { id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<p '));
|
||||
assert.ok(html.includes('a < b<br>ligne 2'));
|
||||
@@ -21,14 +25,18 @@ test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
|
||||
});
|
||||
|
||||
test('titre — balise dynamique h1/h2/h3 selon _niveau', () => {
|
||||
const el = { id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {} };
|
||||
const el = {
|
||||
id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<h1 '));
|
||||
assert.ok(html.includes('class="title"'));
|
||||
});
|
||||
|
||||
test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () => {
|
||||
const el = { id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<button '));
|
||||
assert.ok(html.includes('class="button"'));
|
||||
@@ -36,7 +44,9 @@ test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () =
|
||||
});
|
||||
|
||||
test('image — self-closing, pas de contenu ni enfants', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal(html.indexOf('</img>'), -1);
|
||||
assert.ok(html.startsWith('<img '));
|
||||
@@ -44,26 +54,36 @@ test('image — self-closing, pas de contenu ni enfants', () => {
|
||||
});
|
||||
|
||||
test('image — alt="" par défaut (RGAA 1.1.1, décoratif faute de champ de description dédié)', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('alt=""'));
|
||||
});
|
||||
|
||||
test('image — un alt déjà posé par l\'auteur n\'est jamais écrasé', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('alt="Un chat noir"'));
|
||||
});
|
||||
|
||||
test('liste — une <li> par ligne de contenu, échappée', () => {
|
||||
const el = { id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<li>Pomme</li><li>Poire < Kiwi</li>'));
|
||||
});
|
||||
|
||||
test('conteneur avec enfants — rendu récursif', () => {
|
||||
const parent = { id: 10, widget: 'conteneur', content: '', attributes: {}, style: {} };
|
||||
const child = { id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10 };
|
||||
const parent = {
|
||||
id: 10, widget: 'conteneur', content: '', attributes: {}, style: {},
|
||||
};
|
||||
const child = {
|
||||
id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 10: [child] }, {});
|
||||
assert.ok(html.includes('>enfant<'));
|
||||
});
|
||||
@@ -72,10 +92,15 @@ test('condition de visibilité fausse en mode jouable -> placeholder masqué', (
|
||||
const gd = baseGameData();
|
||||
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
|
||||
const el = {
|
||||
id: 6, widget: 'texte', content: 'secret', style: {},
|
||||
id: 6,
|
||||
widget: 'texte',
|
||||
content: 'secret',
|
||||
style: {},
|
||||
attributes: {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '99',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, { _forge_play_mode: true });
|
||||
@@ -88,10 +113,15 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
|
||||
const gd = baseGameData();
|
||||
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
|
||||
const el = {
|
||||
id: 7, widget: 'texte', content: 'visible en édition', style: {},
|
||||
id: 7,
|
||||
widget: 'texte',
|
||||
content: 'visible en édition',
|
||||
style: {},
|
||||
attributes: {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '99',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {}); // pas de _forge_play_mode
|
||||
@@ -100,7 +130,9 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
|
||||
});
|
||||
|
||||
test('{{champ}} substitué dans le contenu (contexte de Répéteur)', () => {
|
||||
const el = { id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, { nom: 'Alice' });
|
||||
assert.ok(html.includes('Bonjour Alice'));
|
||||
});
|
||||
@@ -113,7 +145,9 @@ test('forgeStyleString — largeur par défaut selon axe flex du parent (enfant)
|
||||
});
|
||||
|
||||
test('forgeAttrString — booléen True = attribut sans valeur, False/null omis', () => {
|
||||
const s = forgeAttrString({ required: true, disabled: false, placeholder: null, name: 'x' }, 'color:red;');
|
||||
const s = forgeAttrString({
|
||||
required: true, disabled: false, placeholder: null, name: 'x',
|
||||
}, 'color:red;');
|
||||
assert.ok(s.includes('style="color:red;"'));
|
||||
assert.ok(s.includes('required'));
|
||||
assert.ok(!s.includes('disabled'));
|
||||
|
||||
@@ -8,35 +8,43 @@ const { forgeRenderElementHtml } = require('./_load_offline_runtime.js');
|
||||
function baseGameData() {
|
||||
return {
|
||||
variables: {},
|
||||
definition_names: { '1': 'Scores' },
|
||||
fields_meta: { '1': [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
|
||||
data: { '1': [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
|
||||
personnage_animations: { '5': { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
|
||||
definition_names: { 1: 'Scores' },
|
||||
fields_meta: { 1: [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
|
||||
data: { 1: [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
|
||||
personnage_animations: { 5: { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
|
||||
element_type_templates: {},
|
||||
};
|
||||
}
|
||||
|
||||
test('case à cocher — <label class="checkbox"><input type="checkbox">', () => {
|
||||
const el = { id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
// style toujours avant les autres attributs (voir forgeAttrString /
|
||||
// attr_string.py — le même ordre côté serveur).
|
||||
assert.ok(html.startsWith('<label style='));
|
||||
assert.ok(html.includes('class="checkbox"'));
|
||||
assert.ok(html.includes('type="checkbox"'));
|
||||
assert.ok(html.includes("J'accepte")); // échappé, comme html.escape() côté serveur
|
||||
assert.ok(html.includes('J'accepte')); // échappé, comme html.escape() côté serveur
|
||||
});
|
||||
|
||||
test('liste déroulante — une <option> par ligne de contenu', () => {
|
||||
const el = { id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<div class="select is-fullwidth">'));
|
||||
assert.equal((html.match(/<option/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test('groupe de champs — <fieldset><legend> + enfants', () => {
|
||||
const parent = { id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {} };
|
||||
const child = { id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3 };
|
||||
const parent = {
|
||||
id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {},
|
||||
};
|
||||
const child = {
|
||||
id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 3: [child] }, {});
|
||||
assert.ok(html.startsWith('<fieldset '));
|
||||
assert.ok(html.includes('<legend>Coordonnées</legend>'));
|
||||
@@ -44,22 +52,28 @@ test('groupe de champs — <fieldset><legend> + enfants', () => {
|
||||
});
|
||||
|
||||
test('tableau — une ligne par ligne de contenu, cellules séparées par ";"', () => {
|
||||
const el = { id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<th>Nom</th><th>Score</th>'));
|
||||
assert.ok(html.includes('<td>Alice</td><td>42</td>'));
|
||||
});
|
||||
|
||||
test('icône — repli sur "star" si _icon_slug absent', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
// le style entier passe par forgeHtmlEscape (comme html.escape() côté
|
||||
// serveur) : l'apostrophe de url('...') devient '.
|
||||
assert.ok(html.includes("--icon-url:url('icons/star.svg')"));
|
||||
assert.ok(html.includes('--icon-url:url('icons/star.svg')'));
|
||||
});
|
||||
|
||||
test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute de description)', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('aria-hidden="true"'));
|
||||
});
|
||||
@@ -72,17 +86,25 @@ test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute d
|
||||
// l'export, voir build_scorm_package.py::_build_icon_data_uris) contourne
|
||||
// entièrement le problème : aucune requête réseau séparée pour une data: URI.
|
||||
test('icône — utilise gameData.icon_data_uris quand pré-encodée (contourne le blocage CORS de Firefox sous file://)', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {},
|
||||
};
|
||||
const gd = baseGameData();
|
||||
gd.icon_data_uris = { envelope: 'data:image/svg+xml;base64,QUJD' };
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.ok(html.includes("--icon-url:url('data:image/svg+xml;base64,QUJD')"));
|
||||
assert.ok(html.includes('--icon-url:url('data:image/svg+xml;base64,QUJD')'));
|
||||
});
|
||||
|
||||
test('onglets — un panneau actif, les autres masqués', () => {
|
||||
const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} };
|
||||
const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
|
||||
const tab2 = { id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
|
||||
const parent = {
|
||||
id: 7, widget: 'onglets', attributes: {}, style: {},
|
||||
};
|
||||
const tab1 = {
|
||||
id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
|
||||
};
|
||||
const tab2 = {
|
||||
id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 7: [tab1, tab2] }, {});
|
||||
assert.ok(html.includes('is-active'));
|
||||
assert.ok(html.includes('tabpanel-8'));
|
||||
@@ -90,7 +112,9 @@ test('onglets — un panneau actif, les autres masqués', () => {
|
||||
});
|
||||
|
||||
test('personnage — lit gameData.personnage_animations (idle en premier)', () => {
|
||||
const el = { id: 5, widget: 'personnage', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'personnage', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<img '));
|
||||
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
|
||||
@@ -100,8 +124,12 @@ test('personnage — lit gameData.personnage_animations (idle en premier)', () =
|
||||
|
||||
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
|
||||
const el = {
|
||||
id: 10, widget: 'jauge', style: {},
|
||||
attributes: { _definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff' },
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="42"')); // ligne la plus récente = Alice (id 1, points=42)
|
||||
@@ -110,8 +138,11 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
|
||||
|
||||
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
const el = {
|
||||
id: 11, widget: 'repeteur_donnees', content: '{{nom}} : {{points}} pts',
|
||||
attributes: { _definition_id: '1' }, style: {},
|
||||
id: 11,
|
||||
widget: 'repeteur_donnees',
|
||||
content: '{{nom}} : {{points}} pts',
|
||||
attributes: { _definition_id: '1' },
|
||||
style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal((html.match(/repeaterItem/g) || []).length, 2);
|
||||
@@ -121,14 +152,19 @@ test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
});
|
||||
|
||||
test('répéteur — objet introuvable, message explicite plutôt qu\'un plantage', () => {
|
||||
const el = { id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {} };
|
||||
const el = {
|
||||
id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('Objet introuvable'));
|
||||
});
|
||||
|
||||
test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', () => {
|
||||
const el = {
|
||||
id: 13, widget: 'texte', content: 'Score actuel : {{points}}', style: {},
|
||||
id: 13,
|
||||
widget: 'texte',
|
||||
content: 'Score actuel : {{points}}',
|
||||
style: {},
|
||||
attributes: {
|
||||
_data_definition_id: '1',
|
||||
_data_filtres_json: JSON.stringify([{ champ: 'nom', operateur: 'egal', valeur: 'Bob' }]),
|
||||
@@ -140,7 +176,9 @@ test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', ()
|
||||
});
|
||||
|
||||
test('superposition — voile plein écran seulement en mode jouable', () => {
|
||||
const el = { id: 14, widget: 'superposition', content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 14, widget: 'superposition', content: '', attributes: {}, style: {},
|
||||
};
|
||||
const editorHtml = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal(editorHtml.indexOf('position:fixed'), -1);
|
||||
const playHtml = forgeRenderElementHtml(el, baseGameData(), {}, { _forge_play_mode: true });
|
||||
@@ -151,10 +189,14 @@ test('superposition — voile plein écran seulement en mode jouable', () => {
|
||||
test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameData.element_type_templates', () => {
|
||||
const gd = baseGameData();
|
||||
gd.element_type_templates['20'] = {
|
||||
top: [{ id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root' }],
|
||||
top: [{
|
||||
id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root',
|
||||
}],
|
||||
children_map: {},
|
||||
};
|
||||
const el = { id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.ok(html.includes('>Modèle<'));
|
||||
});
|
||||
@@ -162,10 +204,14 @@ test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameDa
|
||||
test('élément de jeu = superposition seule -> enveloppe transparente sans classe "box"', () => {
|
||||
const gd = baseGameData();
|
||||
gd.element_type_templates['21'] = {
|
||||
top: [{ id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root' }],
|
||||
top: [{
|
||||
id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root',
|
||||
}],
|
||||
children_map: {},
|
||||
};
|
||||
const el = { id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.equal(html.indexOf('class="box"'), -1);
|
||||
assert.ok(html.startsWith('<div '));
|
||||
|
||||
@@ -24,8 +24,11 @@ function fakeApi() {
|
||||
|
||||
test('FORGE_TO_SCORM_STATUS — vocabulaire figé cmi.core.lesson_status', () => {
|
||||
assert.deepEqual(FORGE_TO_SCORM_STATUS, {
|
||||
non_commence: 'not attempted', en_cours: 'incomplete', termine: 'completed',
|
||||
reussi: 'passed', echoue: 'failed',
|
||||
non_commence: 'not attempted',
|
||||
en_cours: 'incomplete',
|
||||
termine: 'completed',
|
||||
reussi: 'passed',
|
||||
echoue: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +46,7 @@ test('forgeScormInit ne pose PAS cmi.core.score.min/max si le jeu n\'a aucune qu
|
||||
global.window.FORGE_MAX_QUIZ_SCORE = 0;
|
||||
try {
|
||||
forgeScormInit();
|
||||
assert.ok(!api.calls.some(c => c[1] === 'cmi.core.score.max'));
|
||||
assert.ok(!api.calls.some((c) => c[1] === 'cmi.core.score.max'));
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
|
||||
@@ -54,8 +57,8 @@ test('forgeScormInit pose cmi.core.score.min/max une fois si le jeu a des questi
|
||||
try {
|
||||
forgeScormInit();
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'LMSSetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']]
|
||||
api.calls.filter((c) => c[0] === 'LMSSetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']],
|
||||
);
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
@@ -68,8 +71,8 @@ test('forgeScormSyncScore envoie score.raw/lesson_status et Commit, une seule fo
|
||||
forgeScormInit();
|
||||
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
|
||||
const callsAfterFirst = api.calls.length;
|
||||
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
|
||||
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
|
||||
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
|
||||
assert.equal(api.calls.length, callsAfterFirst);
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
|
||||
@@ -20,7 +20,7 @@ function fakeApi(interactionsCount) {
|
||||
calls,
|
||||
Initialize: () => calls.push(['Initialize']),
|
||||
SetValue: (key, value) => calls.push(['SetValue', key, value]),
|
||||
GetValue: key => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
|
||||
GetValue: (key) => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
|
||||
Commit: () => calls.push(['Commit']),
|
||||
};
|
||||
}
|
||||
@@ -52,8 +52,8 @@ test('forgeScorm2004Init pose cmi.score.min/max une fois si le jeu a des questio
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.score.min', '0'], ['cmi.score.max', '20']]
|
||||
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.score.min', '0'], ['cmi.score.max', '20']],
|
||||
);
|
||||
} finally { delete global.window.API_1484_11; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
@@ -66,10 +66,10 @@ test('forgeScorm2004SyncScore — "echoue" envoie completion_status="completed"
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'echoue' } });
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']]
|
||||
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']],
|
||||
);
|
||||
assert.ok(api.calls.some(c => c[0] === 'Commit'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
|
||||
} finally { delete global.window.API_1484_11; }
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ test('forgeScorm2004SyncScore — ajoute cmi.score.scaled UNIQUEMENT si des ques
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'reussi' } });
|
||||
const scaledCall = api.calls.find(c => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
|
||||
const scaledCall = api.calls.find((c) => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
|
||||
assert.equal(scaledCall[2], String(3 / 4));
|
||||
} finally {
|
||||
delete global.window.API_1484_11;
|
||||
@@ -117,7 +117,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.type'], 'choice');
|
||||
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
@@ -125,7 +125,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
assert.equal(sets['cmi.interactions.2.learner_response'], '0');
|
||||
assert.equal(sets['cmi.interactions.2.result'], 'correct');
|
||||
assert.equal(sets['cmi.interactions.2.correct_responses.0.pattern'], '0');
|
||||
assert.ok(api.calls.some(c => c[0] === 'Commit'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
|
||||
} finally { delete global.window.API_1484_11; }
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
assert.equal(sets['cmi.interactions.0.correct_responses.0.pattern'], '0');
|
||||
|
||||
@@ -7,9 +7,9 @@ const { forgeIsElementVisibleOffline } = require('../visibility-condition.js');
|
||||
function baseGameData() {
|
||||
return {
|
||||
variables: { niveau: { value: '3', type: 'nombre_entier' } },
|
||||
definition_names: { '1': 'Progression' },
|
||||
fields_meta: { '1': [{ name: 'debloque', type: 'booleen' }] },
|
||||
data: { '1': [{ id: 1, debloque: 1 }] },
|
||||
definition_names: { 1: 'Progression' },
|
||||
fields_meta: { 1: [{ name: 'debloque', type: 'booleen' }] },
|
||||
data: { 1: [{ id: 1, debloque: 1 }] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,28 +21,37 @@ test('sans réglage de condition, toujours visible', () => {
|
||||
test('mode variable — égal/supérieur', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '3',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal',
|
||||
_visibilite_cond_valeur: '3',
|
||||
}), true);
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur', _visibilite_cond_valeur: '3',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur',
|
||||
_visibilite_cond_valeur: '3',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('mode variable — variable supprimée depuis : ne bloque jamais', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'disparue',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '1',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'disparue',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '1',
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('mode objet — lit la ligne la plus récente hors répéteur', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
|
||||
_visibilite_cond_mode: 'objet',
|
||||
_visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: 'oui',
|
||||
}), true);
|
||||
});
|
||||
|
||||
@@ -50,7 +59,10 @@ test('mode objet — utilise ctx (ligne du Répéteur) plutôt que la ligne la p
|
||||
const gd = baseGameData();
|
||||
const ctx = { debloque: 0 };
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
|
||||
_visibilite_cond_mode: 'objet',
|
||||
_visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: 'oui',
|
||||
}, ctx), false);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.window.addEventListener = global.window.addEventListener || function () {};
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
global.btoa = global.btoa || ((str) => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
@@ -16,8 +16,11 @@ const {
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.window.FORGE_XAPI_CONFIG = {
|
||||
endpoint: 'https://lrs.example.com/xapi', login: 'user', password: 'pass',
|
||||
activity_id: 'urn:forge-engine:game:test', activity_name: 'Mon jeu',
|
||||
endpoint: 'https://lrs.example.com/xapi',
|
||||
login: 'user',
|
||||
password: 'pass',
|
||||
activity_id: 'urn:forge-engine:game:test',
|
||||
activity_name: 'Mon jeu',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,7 +41,7 @@ test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant l
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed'); // NOSONAR S5332
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
@@ -48,7 +51,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered'); // NOSONAR S5332
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
|
||||
assert.equal(body.result.success, true);
|
||||
assert.equal(body.result.score.raw, 5);
|
||||
|
||||
@@ -18,24 +18,24 @@
|
||||
// full_game_payload.py) pour que conditions.js continue de la lire
|
||||
// exactement comme aujourd'hui.
|
||||
function forgeApplyVariableActionOffline(gameData, action) {
|
||||
var varName = action.target_variable;
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const varName = action.target_variable;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!varName || !operation) return false;
|
||||
var variable = gameData.variables && gameData.variables[varName];
|
||||
const variable = gameData.variables && gameData.variables[varName];
|
||||
if (!variable) return false;
|
||||
var varType = variable.type;
|
||||
var current = variable.value;
|
||||
if (varType === "nombre_entier" || varType === "nombre_decimal") {
|
||||
current = (current === null || current === undefined || current === "") ? 0 : parseFloat(current);
|
||||
if (isNaN(current)) current = 0;
|
||||
} else if (varType === "booleen") {
|
||||
var normalized = String(current == null ? "" : current).trim().toLowerCase();
|
||||
current = (normalized === "1" || normalized === "true" || normalized === "vrai") ? 1 : 0;
|
||||
const varType = variable.type;
|
||||
let current = variable.value;
|
||||
if (varType === 'nombre_entier' || varType === 'nombre_decimal') {
|
||||
current = (current === null || current === undefined || current === '') ? 0 : parseFloat(current);
|
||||
if (Number.isNaN(current)) current = 0;
|
||||
} else if (varType === 'booleen') {
|
||||
const normalized = String(current == null ? '' : current).trim().toLowerCase();
|
||||
current = (normalized === '1' || normalized === 'true' || normalized === 'vrai') ? 1 : 0;
|
||||
}
|
||||
var newValue;
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, varType === "nombre_decimal");
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, varType === 'nombre_decimal');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -47,15 +47,15 @@ function forgeApplyVariableActionOffline(gameData, action) {
|
||||
// Port de apply_score_action.py/apply_status_action.py — voir db/scoring/
|
||||
// pour le vocabulaire des statuts (db.SCORE_STATUS_LABELS côté serveur,
|
||||
// dupliqué ici en dur : ces 5 clés sont figées, voir db/constants.py).
|
||||
var FORGE_SCORE_STATUS_KEYS = ["non_commence", "en_cours", "termine", "reussi", "echoue"];
|
||||
const FORGE_SCORE_STATUS_KEYS = ['non_commence', 'en_cours', 'termine', 'reussi', 'echoue'];
|
||||
|
||||
function forgeApplyScoreActionOffline(gameData, action) {
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!operation) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
var current = gameData.scoring.score || 0;
|
||||
var newValue;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
|
||||
const current = gameData.scoring.score || 0;
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, true);
|
||||
} catch (e) {
|
||||
@@ -65,16 +65,16 @@ function forgeApplyScoreActionOffline(gameData, action) {
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
|
||||
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
|
||||
// ou réglage xAPI jamais configuré pour ce jeu).
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
|
||||
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
function forgeApplyStatusActionOffline(gameData, action) {
|
||||
var status = action.data_value;
|
||||
const status = action.data_value;
|
||||
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
|
||||
gameData.scoring.status = status;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
|
||||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,45 +86,45 @@ function forgeApplyStatusActionOffline(gameData, action) {
|
||||
// {name, type, min_value, max_value} — même forme que le payload en
|
||||
// ligne (screens/payload/full_game_payload.py).
|
||||
function forgeFindFieldMeta(gameData, definitionId, fieldName) {
|
||||
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fields) return null;
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fields[i].name === fieldName) return fields[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forgeClampToFieldBounds(value, fieldMeta) {
|
||||
if (fieldMeta.type !== "nombre_entier" && fieldMeta.type !== "nombre_decimal") return value;
|
||||
if (fieldMeta.type !== 'nombre_entier' && fieldMeta.type !== 'nombre_decimal') return value;
|
||||
if (fieldMeta.min_value !== null && fieldMeta.min_value !== undefined && value < fieldMeta.min_value) {
|
||||
value = fieldMeta.min_value;
|
||||
}
|
||||
if (fieldMeta.max_value !== null && fieldMeta.max_value !== undefined && value > fieldMeta.max_value) {
|
||||
value = fieldMeta.max_value;
|
||||
}
|
||||
return fieldMeta.type === "nombre_decimal" ? value : Math.trunc(value);
|
||||
return fieldMeta.type === 'nombre_decimal' ? value : Math.trunc(value);
|
||||
}
|
||||
|
||||
function forgeApplyDataActionOffline(gameData, action) {
|
||||
var definitionId = String(action.target_definition_id || "");
|
||||
var rowId = action.target_row_id;
|
||||
var fieldName = action.target_field;
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const definitionId = String(action.target_definition_id || '');
|
||||
const rowId = action.target_row_id;
|
||||
const fieldName = action.target_field;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!(definitionId && rowId && fieldName && operation)) return false;
|
||||
var fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
|
||||
const fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
|
||||
if (!fieldMeta) return false;
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows) return false;
|
||||
var row = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
let row = null;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].id === rowId) { row = rows[i]; break; }
|
||||
}
|
||||
if (!row) return false;
|
||||
var current = row[fieldName];
|
||||
var newValue;
|
||||
const current = row[fieldName];
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === "nombre_decimal");
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === 'nombre_decimal');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -140,17 +140,17 @@ function forgeApplyDataActionOffline(gameData, action) {
|
||||
// tout le reste -> null). L'id est un simple auto-incrément local (pas
|
||||
// de vraie base : aucune ligne d'un autre joueur à ignorer hors ligne).
|
||||
function forgeApplyAddRowActionOffline(gameData, action) {
|
||||
var definitionId = String(action.target_definition_id || "");
|
||||
const definitionId = String(action.target_definition_id || '');
|
||||
if (!definitionId) return null;
|
||||
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fields) return null;
|
||||
if (!gameData.data[definitionId]) gameData.data[definitionId] = [];
|
||||
var rows = gameData.data[definitionId];
|
||||
var maxId = 0;
|
||||
rows.forEach(function (r) { if (r.id > maxId) maxId = r.id; });
|
||||
var newRow = { id: maxId + 1 };
|
||||
fields.forEach(function (f) {
|
||||
newRow[f.name] = f.type === "booleen" ? 0 : null;
|
||||
const rows = gameData.data[definitionId];
|
||||
let maxId = 0;
|
||||
rows.forEach((r) => { if (r.id > maxId) maxId = r.id; });
|
||||
const newRow = { id: maxId + 1 };
|
||||
fields.forEach((f) => {
|
||||
newRow[f.name] = f.type === 'booleen' ? 0 : null;
|
||||
});
|
||||
rows.push(newRow);
|
||||
return newRow.id;
|
||||
@@ -158,9 +158,14 @@ function forgeApplyAddRowActionOffline(gameData, action) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline,
|
||||
forgeApplyDataActionOffline, forgeApplyAddRowActionOffline,
|
||||
forgeFindFieldMeta, forgeClampToFieldBounds, FORGE_SCORE_STATUS_KEYS,
|
||||
forgeApplyVariableActionOffline,
|
||||
forgeApplyScoreActionOffline,
|
||||
forgeApplyStatusActionOffline,
|
||||
forgeApplyDataActionOffline,
|
||||
forgeApplyAddRowActionOffline,
|
||||
forgeFindFieldMeta,
|
||||
forgeClampToFieldBounds,
|
||||
FORGE_SCORE_STATUS_KEYS,
|
||||
};
|
||||
// node:test charge ce fichier isolément (voir __tests__/) — forgeComputeNewValue
|
||||
// vit dans compute-operation.js, jamais un <script> séparé en test.
|
||||
|
||||
@@ -19,50 +19,52 @@ function forgePyMod(a, b) {
|
||||
}
|
||||
|
||||
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||
var round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === "definir_texte") return rawValue || "";
|
||||
if (operation === "definir_nombre") {
|
||||
var n = parseFloat(rawValue);
|
||||
return isNaN(n) ? 0 : round(n);
|
||||
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") {
|
||||
var amount = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
|
||||
if (isNaN(amount)) amount = 0;
|
||||
if (operation === "decrementer") amount = -amount;
|
||||
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") {
|
||||
var factor = (rawValue === null || rawValue === undefined || rawValue === "") ? 1 : parseFloat(rawValue);
|
||||
if (isNaN(factor)) factor = 1;
|
||||
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") {
|
||||
var divisor = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
|
||||
if (isNaN(divisor)) divisor = 0;
|
||||
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;
|
||||
var result = operation === "diviser" ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
if (operation === "minimum" || operation === "maximum") {
|
||||
var bound = parseFloat(rawValue);
|
||||
if (isNaN(bound)) return current;
|
||||
var res = operation === "minimum" ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
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") {
|
||||
var parts = String(rawValue || "").split(",");
|
||||
var lo = parseFloat(parts[0]), hi = parseFloat(parts[1]);
|
||||
if (isNaN(lo)) lo = 0;
|
||||
if (isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { var tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo;
|
||||
var iLo = Math.trunc(lo), iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo;
|
||||
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);
|
||||
throw new Error(`opération inconnue : ${operation}`);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
|
||||
@@ -7,36 +7,36 @@
|
||||
// _compare() côté Python (chargé avant ce fichier dans templates/
|
||||
// play.html), pas de troisième copie de cette logique.
|
||||
|
||||
var FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
var FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
var FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
|
||||
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
|
||||
// l'ancien format à 2 filtres fixes si l'attribut JSON est absent, même
|
||||
// convention que côté serveur (aucune migration de données nécessaire).
|
||||
function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
var raw = attrs[clausesAttr];
|
||||
var clauses = null;
|
||||
const raw = attrs[clausesAttr];
|
||||
let clauses = null;
|
||||
if (raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) clauses = parsed;
|
||||
} catch (e) { clauses = null; }
|
||||
}
|
||||
if (clauses === null && legacyPrefix) {
|
||||
clauses = [];
|
||||
["", "2"].forEach(function (suffix) {
|
||||
var champ = (attrs[legacyPrefix + suffix + "_champ"] || "").trim();
|
||||
['', '2'].forEach((suffix) => {
|
||||
const champ = (attrs[`${legacyPrefix + suffix}_champ`] || '').trim();
|
||||
if (champ) {
|
||||
clauses.push({
|
||||
champ: champ,
|
||||
operateur: attrs[legacyPrefix + suffix + "_operateur"] || "egal",
|
||||
valeur: attrs[legacyPrefix + suffix + "_valeur"] || "",
|
||||
champ,
|
||||
operateur: attrs[`${legacyPrefix + suffix}_operateur`] || 'egal',
|
||||
valeur: attrs[`${legacyPrefix + suffix}_valeur`] || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || "et" };
|
||||
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || 'et' };
|
||||
}
|
||||
|
||||
// Port de _resolve_variable_path — navigue ".champ"/"[index]" dans une
|
||||
@@ -45,16 +45,17 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
// planter).
|
||||
function forgeResolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
var current;
|
||||
let current;
|
||||
try {
|
||||
current = rawValue ? JSON.parse(rawValue) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
var m;
|
||||
let m;
|
||||
FORGE_PATH_SEGMENT_RE.lastIndex = 0;
|
||||
while ((m = FORGE_PATH_SEGMENT_RE.exec(path)) !== null) {
|
||||
var key = m[1], index = m[2];
|
||||
const key = m[1]; const
|
||||
index = m[2];
|
||||
try {
|
||||
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
|
||||
if (current === undefined) return null;
|
||||
@@ -86,22 +87,23 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
// définition visée par son nom affiché.
|
||||
function forgeResolveFilterValue(gameData, rawValue) {
|
||||
if (!rawValue) return rawValue;
|
||||
var stripped = String(rawValue).trim();
|
||||
const stripped = String(rawValue).trim();
|
||||
|
||||
var varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
if (varMatch) {
|
||||
var variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
const variable = gameData.variables && 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]);
|
||||
}
|
||||
|
||||
var m = FORGE_REF_PATTERN.exec(stripped);
|
||||
const m = FORGE_REF_PATTERN.exec(stripped);
|
||||
if (!m) return rawValue;
|
||||
var objectName = m[1].trim(), fieldName = m[2].trim();
|
||||
var names = gameData.definition_names || {};
|
||||
for (var defId in names) {
|
||||
const objectName = m[1].trim(); const
|
||||
fieldName = m[2].trim();
|
||||
const names = gameData.definition_names || {};
|
||||
for (const defId in names) {
|
||||
if (names[defId] === objectName) {
|
||||
var rows = gameData.data && gameData.data[defId];
|
||||
const rows = gameData.data && gameData.data[defId];
|
||||
if (rows && rows.length) {
|
||||
return rows[0][fieldName];
|
||||
}
|
||||
@@ -113,41 +115,41 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
|
||||
// Port de _clause_matches.
|
||||
function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
var fieldName = (clause.champ || "").trim();
|
||||
const fieldName = (clause.champ || '').trim();
|
||||
if (!fieldName) return true;
|
||||
var operator = clause.operateur || "egal";
|
||||
var expected = forgeResolveFilterValue(gameData, clause.valeur || "");
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; });
|
||||
var fieldType = field ? field.type : "texte";
|
||||
const operator = clause.operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
||||
const fieldsMetaForDef = gameData.fields_meta && 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);
|
||||
}
|
||||
|
||||
// Port de filter_rows_by_clauses.
|
||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||
if (!clauses || !clauses.length) return rows;
|
||||
if (combinator === "ou") {
|
||||
return rows.filter(function (r) {
|
||||
return clauses.some(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
|
||||
});
|
||||
if (combinator === 'ou') {
|
||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
return rows.filter(function (r) {
|
||||
return clauses.every(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
|
||||
});
|
||||
return rows.filter((r) => clauses.every((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
|
||||
// Port de filter_repeater_rows — filtre appliqué par le widget Répéteur
|
||||
// lui-même (attrs._filtres_json/_filtre_combinateur, avec repli sur
|
||||
// l'ancien format à 2 filtres fixes _filtre(2)_champ/...).
|
||||
function forgeFilterRepeaterRows(gameData, definitionId, rows, attrs) {
|
||||
var decoded = forgeDecodeClauses(attrs, "_filtres_json", "_filtre_combinateur", "_filtre");
|
||||
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
|
||||
return forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeDecodeClauses, forgeResolveVariablePath,
|
||||
forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows,
|
||||
forgeDecodeClauses,
|
||||
forgeResolveVariablePath,
|
||||
forgeResolveFilterValue,
|
||||
forgeClauseMatches,
|
||||
forgeFilterRowsByClauses,
|
||||
forgeFilterRepeaterRows,
|
||||
};
|
||||
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
|
||||
}
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
// static/js/play/actions.js, appelée par l'action "Modifier un objet de
|
||||
// scène" — comportement identique en ligne et hors ligne, rien à porter).
|
||||
function forgeRecomputeElementsForScreenOffline(gameData, screen) {
|
||||
var elements = screen.elements || [];
|
||||
var childrenMap = {};
|
||||
elements.forEach(function (el) {
|
||||
const elements = screen.elements || [];
|
||||
const childrenMap = {};
|
||||
elements.forEach((el) => {
|
||||
if (el.parent_id) {
|
||||
if (!childrenMap[el.parent_id]) childrenMap[el.parent_id] = [];
|
||||
childrenMap[el.parent_id].push(el);
|
||||
}
|
||||
});
|
||||
var playCtx = { _forge_play_mode: true };
|
||||
elements.forEach(function (el) {
|
||||
const playCtx = { _forge_play_mode: true };
|
||||
elements.forEach((el) => {
|
||||
el.rendered_html = forgeRenderElementHtml(el, gameData, childrenMap, playCtx);
|
||||
});
|
||||
}
|
||||
|
||||
function forgeRecomputeFullPayloadOffline(gameData) {
|
||||
(gameData.screens || []).forEach(function (s) {
|
||||
if (s.kind !== "jeu_2d") forgeRecomputeElementsForScreenOffline(gameData, s);
|
||||
(gameData.screens || []).forEach((s) => {
|
||||
if (s.kind !== 'jeu_2d') forgeRecomputeElementsForScreenOffline(gameData, s);
|
||||
});
|
||||
return gameData;
|
||||
}
|
||||
|
||||
@@ -15,68 +15,83 @@
|
||||
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||
// via forgeSpecialRenderer().
|
||||
|
||||
var FORGE_LAYOUT_CAPABLE_WIDGETS = ["conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"];
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['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
|
||||
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
|
||||
// l'éditeur, qui ne concerne que le port serveur).
|
||||
var FORGE_SIMPLE_WIDGETS = {
|
||||
texte: { tag: "p", hasContent: true },
|
||||
titre: { tag: null, hasContent: true, fixedAttrs: { class: "title" } },
|
||||
bouton: { tag: "button", hasContent: true, fixedAttrs: { class: "button" } },
|
||||
lien: { tag: "a", hasContent: true },
|
||||
image: { tag: "img", hasContent: false, selfClosing: true },
|
||||
video: { tag: "video", hasContent: false },
|
||||
champ_texte: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "text", class: "input" } },
|
||||
champ_email: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "email", class: "input" } },
|
||||
champ_mot_de_passe: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "password", class: "input" } },
|
||||
zone_texte: { tag: "textarea", hasContent: false, fixedAttrs: { class: "textarea" } },
|
||||
liste: { tag: "ul", hasContent: true },
|
||||
liste_numerotee: { tag: "ol", hasContent: true },
|
||||
separateur: { tag: "hr", hasContent: false, selfClosing: true },
|
||||
conteneur: { tag: "div", hasContent: true, fixedAttrs: { class: "box" } },
|
||||
const FORGE_SIMPLE_WIDGETS = {
|
||||
texte: { tag: 'p', hasContent: true },
|
||||
titre: { tag: null, hasContent: true, fixedAttrs: { class: 'title' } },
|
||||
bouton: { tag: 'button', hasContent: true, fixedAttrs: { class: 'button' } },
|
||||
lien: { tag: 'a', hasContent: true },
|
||||
image: { tag: 'img', hasContent: false, selfClosing: true },
|
||||
video: { tag: 'video', hasContent: false },
|
||||
champ_texte: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'text', class: 'input' },
|
||||
},
|
||||
champ_email: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'email', class: 'input' },
|
||||
},
|
||||
champ_mot_de_passe: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'password', class: 'input' },
|
||||
},
|
||||
zone_texte: { tag: 'textarea', hasContent: false, fixedAttrs: { class: 'textarea' } },
|
||||
liste: { tag: 'ul', hasContent: true },
|
||||
liste_numerotee: { tag: 'ol', hasContent: true },
|
||||
separateur: { tag: 'hr', hasContent: false, selfClosing: true },
|
||||
conteneur: { tag: 'div', hasContent: true, fixedAttrs: { class: 'box' } },
|
||||
};
|
||||
|
||||
// widget -> clé de special_render (voir screens/widgets/registry.py).
|
||||
var FORGE_SPECIAL_RENDER_BY_WIDGET = {
|
||||
case_a_cocher: "checkbox_label", bouton_radio: "checkbox_label", liste_deroulante: "select_options",
|
||||
groupe_champs: "fieldset_legend", tableau: "table", repeteur_donnees: "repeater",
|
||||
jauge: "jauge", superposition: "overlay", onglets: "onglets", icone: "icone", personnage: "personnage",
|
||||
const FORGE_SPECIAL_RENDER_BY_WIDGET = {
|
||||
case_a_cocher: 'checkbox_label',
|
||||
bouton_radio: 'checkbox_label',
|
||||
liste_deroulante: 'select_options',
|
||||
groupe_champs: 'fieldset_legend',
|
||||
tableau: 'table',
|
||||
repeteur_donnees: 'repeater',
|
||||
jauge: 'jauge',
|
||||
superposition: 'overlay',
|
||||
onglets: 'onglets',
|
||||
icone: 'icone',
|
||||
personnage: 'personnage',
|
||||
};
|
||||
|
||||
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
|
||||
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
|
||||
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
|
||||
var FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: "table" } };
|
||||
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, "'");
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
||||
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
|
||||
function forgeApplyCtx(text, ctx) {
|
||||
if (!ctx || !text) return text;
|
||||
var result = text;
|
||||
Object.keys(ctx).forEach(function (k) {
|
||||
if (k === "_forge_play_mode") return; // indicateur interne, pas un {{champ}}
|
||||
result = result.split("{{" + k + "}}").join(ctx[k]);
|
||||
let result = text;
|
||||
Object.keys(ctx).forEach((k) => {
|
||||
if (k === '_forge_play_mode') return; // indicateur interne, pas un {{champ}}
|
||||
result = result.split(`{{${k}}}`).join(ctx[k]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function forgeAutoId(el) {
|
||||
return "elt-" + el.id;
|
||||
return `elt-${el.id}`;
|
||||
}
|
||||
|
||||
// Port de parent_flex_direction.py.
|
||||
function forgeParentFlexDirection(parentEl) {
|
||||
var style = (parentEl && parentEl.style) || {};
|
||||
if (style.display !== "flex") return null;
|
||||
return style["flex-direction"] || "row";
|
||||
const style = (parentEl && parentEl.style) || {};
|
||||
if (style.display !== 'flex') return null;
|
||||
return style['flex-direction'] || 'row';
|
||||
}
|
||||
|
||||
// Port de style_string.py — même logique de taille par défaut (100% hors
|
||||
@@ -84,34 +99,34 @@ 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) {
|
||||
var style = Object.assign({}, el.style || {});
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !("display" in style)) {
|
||||
style.display = "flex";
|
||||
if (!("flex-direction" in style)) style["flex-direction"] = "column";
|
||||
const style = { ...el.style || {} };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
||||
style.display = 'flex';
|
||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||
}
|
||||
var isChild = !!el.parent_id;
|
||||
var baseStyle;
|
||||
const isChild = !!el.parent_id;
|
||||
let baseStyle;
|
||||
if (isChild) {
|
||||
var widthDefault = (parentFlexDirection === "row" || parentFlexDirection === "row-reverse") ? "auto" : "100%";
|
||||
baseStyle = "width:" + widthDefault + "; height:auto; box-sizing:border-box; margin:0;";
|
||||
const widthDefault = (parentFlexDirection === 'row' || parentFlexDirection === 'row-reverse') ? 'auto' : '100%';
|
||||
baseStyle = `width:${widthDefault}; height:auto; box-sizing:border-box; margin:0;`;
|
||||
} else {
|
||||
baseStyle = "width:100%; height:100%; box-sizing:border-box; margin:0;";
|
||||
baseStyle = 'width:100%; height:100%; box-sizing:border-box; margin:0;';
|
||||
}
|
||||
var hasSizeOverride = false;
|
||||
var parts = [];
|
||||
Object.keys(style).forEach(function (k) {
|
||||
var v = style[k];
|
||||
if (v === null || v === undefined || v === "") return;
|
||||
if (typeof v === "object") return; // filet de sécurité, voir style_string.py
|
||||
if (k === "transform" && !isChild) return;
|
||||
var strV = String(v).trim();
|
||||
if ((k === "width" || k === "height") && (strV === "0px" || strV === "0")) return;
|
||||
if (k === "width" || k === "height") hasSizeOverride = true;
|
||||
parts.push(k + ":" + v);
|
||||
let hasSizeOverride = false;
|
||||
const parts = [];
|
||||
Object.keys(style).forEach((k) => {
|
||||
const v = style[k];
|
||||
if (v === null || v === undefined || v === '') return;
|
||||
if (typeof v === 'object') return; // filet de sécurité, voir style_string.py
|
||||
if (k === 'transform' && !isChild) return;
|
||||
const strV = String(v).trim();
|
||||
if ((k === 'width' || k === 'height') && (strV === '0px' || strV === '0')) return;
|
||||
if (k === 'width' || k === 'height') hasSizeOverride = true;
|
||||
parts.push(`${k}:${v}`);
|
||||
});
|
||||
if (hasSizeOverride) parts.push("flex-shrink:0");
|
||||
var userStyle = parts.join("; ");
|
||||
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
|
||||
if (hasSizeOverride) parts.push('flex-shrink:0');
|
||||
const userStyle = parts.join('; ');
|
||||
return baseStyle + (userStyle ? ` ${userStyle};` : '');
|
||||
}
|
||||
|
||||
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
|
||||
@@ -120,69 +135,67 @@ function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
// lecteur d'écran) — tous les autres attributs vides sont omis comme
|
||||
// avant (une valeur vide y équivaut à "non réglé").
|
||||
function forgeAttrString(attrs, extraStyle) {
|
||||
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
const parts = [`style="${forgeHtmlEscape(extraStyle)}"`];
|
||||
Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === false) return;
|
||||
if (v === "" && k !== "alt") return;
|
||||
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
|
||||
if (v === '' && k !== 'alt') return;
|
||||
parts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
|
||||
});
|
||||
return parts.join(" ");
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Port de visible_attrs.py.
|
||||
function forgeVisibleAttrs(el, meta, ctx) {
|
||||
var rawAttrs = el.attributes || {};
|
||||
var attrs = {};
|
||||
Object.keys(rawAttrs).forEach(function (k) {
|
||||
if (k.indexOf("_") === 0) return;
|
||||
var v = rawAttrs[k];
|
||||
attrs[k] = typeof v === "string" ? forgeApplyCtx(v, ctx) : v;
|
||||
const rawAttrs = el.attributes || {};
|
||||
const attrs = {};
|
||||
Object.keys(rawAttrs).forEach((k) => {
|
||||
if (k.indexOf('_') === 0) return;
|
||||
const v = rawAttrs[k];
|
||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||
});
|
||||
Object.assign(attrs, meta.fixedAttrs || {});
|
||||
var extraClasses = Object.keys(rawAttrs)
|
||||
.filter(function (k) { return k.indexOf("_class_") === 0 && rawAttrs[k]; })
|
||||
.map(function (k) { return rawAttrs[k]; });
|
||||
const extraClasses = Object.keys(rawAttrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
||||
.map((k) => rawAttrs[k]);
|
||||
if (extraClasses.length) {
|
||||
var baseClass = attrs.class || "";
|
||||
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(" ");
|
||||
const baseClass = attrs.class || '';
|
||||
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(' ');
|
||||
}
|
||||
if (!("id" in attrs)) attrs.id = forgeAutoId(el);
|
||||
attrs["data-element-id"] = el.id;
|
||||
if (el.parent_id) attrs["data-anim-target"] = el.id;
|
||||
if (!('id' in attrs)) attrs.id = forgeAutoId(el);
|
||||
attrs['data-element-id'] = el.id;
|
||||
if (el.parent_id) attrs['data-anim-target'] = el.id;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
// Port de render_children.py.
|
||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||
var kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
var flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir);
|
||||
}).join("");
|
||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
||||
}
|
||||
|
||||
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
|
||||
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
|
||||
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
|
||||
function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
var definitionId = attrs._data_definition_id;
|
||||
let definitionId = attrs._data_definition_id;
|
||||
if (!definitionId) return null;
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) return null;
|
||||
var rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
var decoded = forgeDecodeClauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", "_data_filtre");
|
||||
let rows = (gameData.data && 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;
|
||||
var row = rows[0];
|
||||
var ctx = {};
|
||||
fieldsMetaForDef.forEach(function (f) {
|
||||
const row = rows[0];
|
||||
const ctx = {};
|
||||
fieldsMetaForDef.forEach((f) => {
|
||||
// row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE
|
||||
// du champ (voir full_game_payload.py), jamais par sa colonne SQL —
|
||||
// voir filter-repeater-rows.js pour le détail de ce piège.
|
||||
var v = row[f.name];
|
||||
ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v);
|
||||
const v = row[f.name];
|
||||
ctx[f.name] = (v === null || v === undefined || v === '') ? '' : String(v);
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
@@ -193,21 +206,19 @@ 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) {
|
||||
var templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
||||
var isOverlayOnly = templateTree.top.every(function (c) { return c.widget === "superposition"; });
|
||||
var top = templateTree.top;
|
||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||
let { top } = templateTree;
|
||||
if (isOverlayOnly) {
|
||||
var overlayRoot = Object.assign({}, top[0]);
|
||||
var newStyle = {};
|
||||
Object.keys(overlayRoot.style || {}).forEach(function (k) { if (k !== "display") newStyle[k] = overlayRoot.style[k]; });
|
||||
const overlayRoot = { ...top[0] };
|
||||
const newStyle = {};
|
||||
Object.keys(overlayRoot.style || {}).forEach((k) => { if (k !== 'display') newStyle[k] = overlayRoot.style[k]; });
|
||||
overlayRoot.style = newStyle;
|
||||
top = top.slice();
|
||||
top[0] = overlayRoot;
|
||||
}
|
||||
var html = top.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx);
|
||||
}).join("");
|
||||
const html = top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx)).join('');
|
||||
return [html, isOverlayOnly];
|
||||
}
|
||||
|
||||
@@ -220,16 +231,16 @@ function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
// enfants/onglets/superposition).
|
||||
function forgeSpecialRenderer(specialKey) {
|
||||
switch (specialKey) {
|
||||
case "checkbox_label": return forgeRenderCheckboxOrRadio;
|
||||
case "select_options": return forgeRenderSelect;
|
||||
case "fieldset_legend": return forgeRenderFieldset;
|
||||
case "table": return forgeRenderTable;
|
||||
case "repeater": return forgeRenderRepeater;
|
||||
case "jauge": return forgeRenderJauge;
|
||||
case "overlay": return forgeRenderOverlay;
|
||||
case "onglets": return forgeRenderOnglets;
|
||||
case "icone": return forgeRenderIcone;
|
||||
case "personnage": return forgeRenderPersonnage;
|
||||
case 'checkbox_label': return forgeRenderCheckboxOrRadio;
|
||||
case 'select_options': return forgeRenderSelect;
|
||||
case 'fieldset_legend': return forgeRenderFieldset;
|
||||
case 'table': return forgeRenderTable;
|
||||
case 'repeater': return forgeRenderRepeater;
|
||||
case 'jauge': return forgeRenderJauge;
|
||||
case 'overlay': return forgeRenderOverlay;
|
||||
case 'onglets': return forgeRenderOnglets;
|
||||
case 'icone': return forgeRenderIcone;
|
||||
case 'personnage': return forgeRenderPersonnage;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -238,77 +249,78 @@ function forgeSpecialRenderer(specialKey) {
|
||||
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
|
||||
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
|
||||
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var widgetKey = el.widget;
|
||||
var simple = FORGE_SIMPLE_WIDGETS[widgetKey];
|
||||
var specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
|
||||
var attrsRaw = el.attributes || {};
|
||||
const widgetKey = el.widget;
|
||||
const simple = FORGE_SIMPLE_WIDGETS[widgetKey];
|
||||
const specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
|
||||
const attrsRaw = el.attributes || {};
|
||||
|
||||
var hasVisibilityCondition = attrsRaw._visibilite_cond_mode === "variable" || attrsRaw._visibilite_cond_mode === "objet";
|
||||
var playMode = !!((ctx || {})._forge_play_mode);
|
||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
||||
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>';
|
||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
||||
}
|
||||
var hasDataBinding = !!attrsRaw._data_definition_id;
|
||||
var mark = function (htmlStr) {
|
||||
var markers = "";
|
||||
if (hasVisibilityCondition) markers += "<!--visibilityGated-->";
|
||||
if (hasDataBinding) markers += "<!--dataBound-->";
|
||||
const hasDataBinding = !!attrsRaw._data_definition_id;
|
||||
const mark = function (htmlStr) {
|
||||
let markers = '';
|
||||
if (hasVisibilityCondition) markers += '<!--visibilityGated-->';
|
||||
if (hasDataBinding) markers += '<!--dataBound-->';
|
||||
return htmlStr + markers;
|
||||
};
|
||||
|
||||
var boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = Object.assign({}, ctx || {}, boundCtx);
|
||||
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
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"),
|
||||
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
|
||||
var meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
|
||||
const meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
|
||||
if (specialKey) {
|
||||
var renderer = forgeSpecialRenderer(specialKey);
|
||||
const renderer = forgeSpecialRenderer(specialKey);
|
||||
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
|
||||
}
|
||||
|
||||
var tag = meta.tag;
|
||||
let { tag } = meta;
|
||||
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
||||
var niveau = attrsRaw._niveau || "2";
|
||||
tag = "h" + niveau;
|
||||
const niveau = attrsRaw._niveau || '2';
|
||||
tag = `h${niveau}`;
|
||||
}
|
||||
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, widgetKey);
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, widgetKey);
|
||||
|
||||
var hoverText = attrsRaw._survol_texte;
|
||||
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
|
||||
const hoverText = attrsRaw._survol_texte;
|
||||
if (hoverText) attrs['data-hover-text'] = forgeApplyCtx(hoverText, ctx);
|
||||
|
||||
// alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de
|
||||
// description dédié dans l'éditeur — alt="" (décoratif) par défaut
|
||||
// plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par
|
||||
// défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through
|
||||
// via forgeVisibleAttrs, si l'auteur en règle un un jour).
|
||||
if (tag === "img" && !("alt" in attrs)) attrs.alt = "";
|
||||
if (tag === 'img' && !('alt' in attrs)) attrs.alt = '';
|
||||
|
||||
if (meta.selfClosing) {
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>`);
|
||||
}
|
||||
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
|
||||
var childHtml = childResult[0], isOverlayTemplate = childResult[1];
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
|
||||
let childHtml = childResult[0]; const
|
||||
isOverlayTemplate = childResult[1];
|
||||
if (childHtml === null) {
|
||||
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
} else if (isOverlayTemplate) {
|
||||
delete attrs.class;
|
||||
return mark("<div " + forgeAttrString(attrs, style) + ">" + childHtml + "</div>");
|
||||
return mark(`<div ${forgeAttrString(attrs, style)}>${childHtml}</div>`);
|
||||
}
|
||||
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
var items = content.split("\n").map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
var inner = items.map(function (item) { return "<li>" + forgeHtmlEscape(item) + "</li>"; }).join("") + childHtml;
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + inner + "</" + tag + ">");
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
const items = content.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const inner = items.map((item) => `<li>${forgeHtmlEscape(item)}</li>`).join('') + childHtml;
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
||||
}
|
||||
|
||||
var safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, "<br>") : "";
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + safeContent + childHtml + "</" + tag + ">");
|
||||
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||
}
|
||||
|
||||
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
|
||||
@@ -324,14 +336,24 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString,
|
||||
forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml,
|
||||
forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer,
|
||||
FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET,
|
||||
forgeHtmlEscape,
|
||||
forgeApplyCtx,
|
||||
forgeAutoId,
|
||||
forgeParentFlexDirection,
|
||||
forgeStyleString,
|
||||
forgeAttrString,
|
||||
forgeVisibleAttrs,
|
||||
forgeRenderChildren,
|
||||
forgeRenderElementHtml,
|
||||
forgeResolveBoundRowCtx,
|
||||
forgeRenderElementTypeChildren,
|
||||
forgeSpecialRenderer,
|
||||
FORGE_SIMPLE_WIDGETS,
|
||||
FORGE_SPECIAL_RENDER_BY_WIDGET,
|
||||
};
|
||||
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|
||||
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
|
||||
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
|
||||
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
// au lieu d'une requête SQL (voir _read_field_value côté serveur).
|
||||
|
||||
function forgeHexToRgb(hexColor) {
|
||||
var hex = (hexColor || "#5b8cff").replace(/^#/, "");
|
||||
if (hex.length !== 6) hex = "5b8cff";
|
||||
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)];
|
||||
}
|
||||
|
||||
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
var lo = forgeHexToRgb(lowHex), hi = forgeHexToRgb(highHex);
|
||||
var mixed = lo.map(function (c, i) { return Math.round(c + (hi[i] - c) * ratio); });
|
||||
var toHex = function (n) { return n.toString(16).padStart(2, "0"); };
|
||||
return "#" + mixed.map(toHex).join("");
|
||||
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('')}`;
|
||||
}
|
||||
|
||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
||||
@@ -22,88 +23,84 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||
if (!definitionId || !fieldName) return null;
|
||||
definitionId = String(definitionId);
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return null;
|
||||
var row = null;
|
||||
if (rowId) row = rows.find(function (r) { return String(r.id) === String(rowId); });
|
||||
let row = null;
|
||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||
if (!row) row = rows[0];
|
||||
return row[fieldName];
|
||||
}
|
||||
|
||||
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = el.attributes || {};
|
||||
var nomPosition = attrs._nom_position || "dessus";
|
||||
var nomAlignement = attrs._nom_alignement || "centre";
|
||||
var nomPolice = attrs._nom_police || "inherit";
|
||||
var nomTaille = attrs._nom_taille || "12";
|
||||
var nomGras = attrs._nom_gras === "1";
|
||||
var nomItalique = attrs._nom_italique === "1";
|
||||
var flexDir = nomPosition === "cote" ? "row" : "column";
|
||||
var alignItems = nomPosition === "cote" ? "center" : "stretch";
|
||||
var style = forgeStyleString(
|
||||
el, parentFlexDirection, el.widget
|
||||
) + " display:flex; flex-direction:" + flexDir + "; align-items:" + alignItems + "; justify-content:center; gap:4px;";
|
||||
const attrs = el.attributes || {};
|
||||
const nomPosition = attrs._nom_position || 'dessus';
|
||||
const nomAlignement = attrs._nom_alignement || 'centre';
|
||||
const nomPolice = attrs._nom_police || 'inherit';
|
||||
const nomTaille = attrs._nom_taille || '12';
|
||||
const nomGras = attrs._nom_gras === '1';
|
||||
const nomItalique = attrs._nom_italique === '1';
|
||||
const flexDir = nomPosition === 'cote' ? 'row' : 'column';
|
||||
const alignItems = nomPosition === 'cote' ? 'center' : 'stretch';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:${flexDir}; align-items:${alignItems}; justify-content:center; gap:4px;`;
|
||||
|
||||
var definitionId = attrs._definition_id;
|
||||
var rowId = attrs._row_id;
|
||||
var fieldName = (attrs._champ || "").trim();
|
||||
var fieldNameNom = (attrs._champ_nom || "").trim();
|
||||
var minV = parseFloat(attrs._min); if (isNaN(minV)) minV = 0;
|
||||
var maxV = parseFloat(attrs._max); if (isNaN(maxV)) maxV = 100;
|
||||
var couleurBasse = attrs._couleur_basse || "#e05252";
|
||||
var couleurHaute = attrs._couleur_haute || "#4caf50";
|
||||
var afficherValeur = attrs._afficher_valeur === "1";
|
||||
const definitionId = attrs._definition_id;
|
||||
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;
|
||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||
const afficherValeur = attrs._afficher_valeur === '1';
|
||||
|
||||
var rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
var value = parseFloat(rawValue);
|
||||
if (isNaN(value)) value = minV;
|
||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
let value = parseFloat(rawValue);
|
||||
if (Number.isNaN(value)) value = minV;
|
||||
|
||||
var span = (maxV - minV) || 1;
|
||||
var ratio = Math.max(0, Math.min(1, (value - minV) / span));
|
||||
var percent = Math.round(ratio * 1000) / 10;
|
||||
var fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
const span = (maxV - minV) || 1;
|
||||
const ratio = Math.max(0, Math.min(1, (value - minV) / span));
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
|
||||
var labelHtml = "";
|
||||
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>';
|
||||
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>`;
|
||||
}
|
||||
|
||||
var nameHtml = "";
|
||||
let nameHtml = '';
|
||||
if (fieldNameNom) {
|
||||
var nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== "") {
|
||||
var 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 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
var extraClasses = Object.keys(attrs)
|
||||
.filter(function (k) { return k.indexOf("_class_") === 0 && attrs[k]; })
|
||||
.map(function (k) { return attrs[k]; }).join(" ");
|
||||
var barClass = forgeHtmlEscape(("progress jaugeBar " + extraClasses).trim());
|
||||
var bar =
|
||||
'<progress class="' + barClass + '" value="' + percent + '" max="100" ' +
|
||||
'style="--bulma-progress-value-background-color:' + fillColor + '; --bulma-progress-bar-background-color:#1c2130; ' +
|
||||
'margin:0; border-radius:6px; transition:background-color .3s ease;">' + percent + '%</progress>';
|
||||
var barWrap =
|
||||
'<div style="position:relative; display:flex; align-items:center; ' +
|
||||
'flex:1 1 auto; min-height:14px;">' + bar + labelHtml + '</div>';
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) + '">' +
|
||||
nameHtml + barWrap + '</div>';
|
||||
const extraClasses = Object.keys(attrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
|
||||
.map((k) => attrs[k]).join(' ');
|
||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
||||
+ `style="--bulma-progress-value-background-color:${fillColor}; --bulma-progress-bar-background-color:#1c2130; `
|
||||
+ `margin:0; border-radius:6px; transition:background-color .3s ease;">${percent}%</progress>`;
|
||||
const barWrap = '<div style="position:relative; display:flex; align-items:center; '
|
||||
+ `flex:1 1 auto; min-height:14px;">${bar}${labelHtml}</div>`;
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)}">${
|
||||
nameHtml}${barWrap}</div>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue };
|
||||
var renderMod = require('./render-element.js');
|
||||
module.exports = {
|
||||
forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue,
|
||||
};
|
||||
const renderMod = require('./render-element.js');
|
||||
global.forgeStyleString = global.forgeStyleString || renderMod.forgeStyleString;
|
||||
global.forgeHtmlEscape = global.forgeHtmlEscape || renderMod.forgeHtmlEscape;
|
||||
global.forgeAutoId = global.forgeAutoId || renderMod.forgeAutoId;
|
||||
|
||||
@@ -4,47 +4,46 @@
|
||||
// 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) {
|
||||
var isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
var attrsRaw = el.attributes || {};
|
||||
var boxColor = attrsRaw._couleur_boite || "#1f2430";
|
||||
var radius = attrsRaw._arrondi || "12";
|
||||
const isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
const attrsRaw = el.attributes || {};
|
||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||
const radius = attrsRaw._arrondi || '12';
|
||||
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style, wrapperClass;
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
let style; let
|
||||
wrapperClass;
|
||||
if (isPlayMode) {
|
||||
var baseOverlayStyle =
|
||||
"position:fixed; inset:0; z-index:9999; display:flex; " +
|
||||
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);";
|
||||
style = baseOverlayStyle + " " + forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
wrapperClass = "modal is-active";
|
||||
const baseOverlayStyle = 'position:fixed; inset:0; z-index:9999; display:flex; '
|
||||
+ 'align-items:center; justify-content:center; background:rgba(0,0,0,0.6);';
|
||||
style = `${baseOverlayStyle} ${forgeStyleString(el, parentFlexDirection, el.widget)}`;
|
||||
wrapperClass = 'modal is-active';
|
||||
} else {
|
||||
style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
wrapperClass = "";
|
||||
wrapperClass = '';
|
||||
}
|
||||
|
||||
var attrParts = ['style="' + forgeHtmlEscape(style) + '"'];
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
if (v === null || v === undefined || v === "" || v === false) return;
|
||||
attrParts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
|
||||
const attrParts = [`style="${forgeHtmlEscape(style)}"`];
|
||||
Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === '' || v === false) return;
|
||||
attrParts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
|
||||
});
|
||||
|
||||
var boxStyle =
|
||||
"background:" + forgeHtmlEscape(String(boxColor)) + "; border-radius:" + forgeHtmlEscape(String(radius)) + "px; " +
|
||||
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;";
|
||||
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
var wrapperClassAttr = wrapperClass ? 'class="' + wrapperClass + '" ' : "";
|
||||
const boxStyle = `background:${forgeHtmlEscape(String(boxColor))}; border-radius:${forgeHtmlEscape(String(radius))}px; `
|
||||
+ 'color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;';
|
||||
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
const wrapperClassAttr = wrapperClass ? `class="${wrapperClass}" ` : '';
|
||||
return (
|
||||
'<div ' + wrapperClassAttr + attrParts.join(" ") + '>' +
|
||||
'<div class="box forgeOverlayBox" style="' + forgeHtmlEscape(boxStyle) + '">' + childHtml + '</div>' +
|
||||
'</div>'
|
||||
`<div ${wrapperClassAttr}${attrParts.join(' ')}>`
|
||||
+ `<div class="box forgeOverlayBox" style="${forgeHtmlEscape(boxStyle)}">${childHtml}</div>`
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderOverlay };
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeVisibleAttrs", "forgeStyleString", "forgeHtmlEscape", "forgeRenderChildren"].forEach(function (name) {
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeVisibleAttrs', 'forgeStyleString', 'forgeHtmlEscape', 'forgeRenderChildren'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,75 +4,71 @@
|
||||
// d'un écran-modèle d'élément de jeu réutilisable, précalculé une fois à
|
||||
// l'export) au lieu de screens/element_types/load_template_tree.py.
|
||||
function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " overflow:auto;";
|
||||
var attrsRaw = el.attributes || {};
|
||||
var definitionId = attrsRaw._definition_id;
|
||||
var definitionAttr = definitionId ? ' data-definition-id="' + forgeHtmlEscape(String(definitionId)) + '"' : "";
|
||||
var templateTypeId = attrsRaw._template_type_id;
|
||||
var template = forgeApplyCtx(el.content || "", ctx);
|
||||
var children = (childrenMap && childrenMap[el.id]) || [];
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} overflow:auto;`;
|
||||
const attrsRaw = el.attributes || {};
|
||||
let definitionId = attrsRaw._definition_id;
|
||||
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]) || [];
|
||||
|
||||
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>';
|
||||
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);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && 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>';
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
||||
}
|
||||
|
||||
var rows = gameData.data[definitionId] || [];
|
||||
let rows = gameData.data[definitionId] || [];
|
||||
rows = forgeFilterRepeaterRows(gameData, definitionId, rows, attrsRaw);
|
||||
|
||||
var templateTree = null;
|
||||
let templateTree = null;
|
||||
if (templateTypeId) {
|
||||
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
|
||||
}
|
||||
|
||||
var items = rows.map(function (row) {
|
||||
var rowCtx = Object.assign({}, ctx || {});
|
||||
const items = rows.map((row) => {
|
||||
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"
|
||||
// (voir filter-repeater-rows.js pour le détail de ce piège).
|
||||
fieldsMetaForDef.forEach(function (f) {
|
||||
var v = row[f.name];
|
||||
rowCtx[f.name] = v === null || v === undefined ? "" : String(v);
|
||||
fieldsMetaForDef.forEach((f) => {
|
||||
const v = row[f.name];
|
||||
rowCtx[f.name] = v === null || v === undefined ? '' : String(v);
|
||||
});
|
||||
var inner;
|
||||
let inner;
|
||||
if (templateTree) {
|
||||
inner = templateTree.top.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx);
|
||||
}).join("");
|
||||
inner = templateTree.top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx)).join('');
|
||||
} else if (children.length) {
|
||||
inner = children.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, childrenMap, rowCtx);
|
||||
}).join("");
|
||||
inner = children.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, rowCtx)).join('');
|
||||
} else {
|
||||
var line = template;
|
||||
Object.keys(rowCtx).forEach(function (fname) {
|
||||
if (fname === "_forge_play_mode") return;
|
||||
line = line.split("{{" + fname + "}}").join(rowCtx[fname]);
|
||||
let line = template;
|
||||
Object.keys(rowCtx).forEach((fname) => {
|
||||
if (fname === '_forge_play_mode') return;
|
||||
line = line.split(`{{${fname}}}`).join(rowCtx[fname]);
|
||||
});
|
||||
inner = forgeHtmlEscape(line);
|
||||
}
|
||||
return '<div class="repeaterItem" data-row-id="' + row.id + '">' + inner + '</div>';
|
||||
return `<div class="repeaterItem" data-row-id="${row.id}">${inner}</div>`;
|
||||
});
|
||||
|
||||
var emptyMsg = attrsRaw._filtre_champ ? "Aucune ligne ne correspond au filtre réglé." : "Aucune donnée dans cet objet pour l'instant.";
|
||||
var innerAll = items.length ? items.join("") : "<em>" + emptyMsg + "</em>";
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '"' + definitionAttr +
|
||||
' style="' + forgeHtmlEscape(style) + '">' + innerAll + '</div>';
|
||||
const emptyMsg = attrsRaw._filtre_champ ? 'Aucune ligne ne correspond au filtre réglé.' : "Aucune donnée dans cet objet pour l'instant.";
|
||||
const innerAll = items.length ? items.join('') : `<em>${emptyMsg}</em>`;
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}"${definitionAttr
|
||||
} style="${forgeHtmlEscape(style)}">${innerAll}</div>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderRepeater };
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeStyleString", "forgeHtmlEscape", "forgeAutoId", "forgeApplyCtx", "forgeRenderElementHtml"].forEach(function (name) {
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeStyleString', 'forgeHtmlEscape', 'forgeAutoId', 'forgeApplyCtx', 'forgeRenderElementHtml'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeFilterRepeaterRows = global.forgeFilterRepeaterRows || filterMod.forgeFilterRepeaterRows;
|
||||
}
|
||||
|
||||
@@ -9,57 +9,56 @@
|
||||
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
|
||||
|
||||
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var inputType = el.widget === "case_a_cocher" ? "checkbox" : "radio";
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const inputType = el.widget === 'case_a_cocher' ? 'checkbox' : 'radio';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs.type = inputType;
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; align-items:center; gap:8px;";
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
return '<label ' + forgeAttrString({ class: inputType }, style) + '><input ' + forgeAttrString(attrs, "") + '> ' + forgeHtmlEscape(content) + '</label>';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; align-items:center; gap:8px;`;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
return `<label ${forgeAttrString({ class: inputType }, style)}><input ${forgeAttrString(attrs, '')}> ${forgeHtmlEscape(content)}</label>`;
|
||||
}
|
||||
|
||||
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var bulmaClasses = outerAttrs.class || "";
|
||||
var selectAttrs = {}, wrapperAttrs = {};
|
||||
Object.keys(outerAttrs).forEach(function (k) {
|
||||
if (k === "id" || k === "data-element-id" || k === "data-anim-target") wrapperAttrs[k] = outerAttrs[k];
|
||||
else if (k !== "class") selectAttrs[k] = outerAttrs[k];
|
||||
const outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const bulmaClasses = outerAttrs.class || '';
|
||||
const selectAttrs = {}; const
|
||||
wrapperAttrs = {};
|
||||
Object.keys(outerAttrs).forEach((k) => {
|
||||
if (k === 'id' || k === 'data-element-id' || k === 'data-anim-target') wrapperAttrs[k] = outerAttrs[k];
|
||||
else if (k !== 'class') selectAttrs[k] = outerAttrs[k];
|
||||
});
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex;";
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var options = content.split("\n").map(function (o) { return o.trim(); }).filter(Boolean);
|
||||
var optsHtml = options.map(function (o) {
|
||||
var esc = forgeHtmlEscape(o);
|
||||
return '<option value="' + esc + '">' + esc + '</option>';
|
||||
}).join("");
|
||||
var selectHtml = '<select ' + forgeAttrString(selectAttrs, "") + '>' + optsHtml + '</select>';
|
||||
var selectWrapClass = forgeHtmlEscape(("select is-fullwidth " + bulmaClasses).trim());
|
||||
return '<div ' + forgeAttrString(wrapperAttrs, style) + '><div class="' + selectWrapClass + '">' + selectHtml + '</div></div>';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex;`;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const options = content.split('\n').map((o) => o.trim()).filter(Boolean);
|
||||
const optsHtml = options.map((o) => {
|
||||
const esc = forgeHtmlEscape(o);
|
||||
return `<option value="${esc}">${esc}</option>`;
|
||||
}).join('');
|
||||
const selectHtml = `<select ${forgeAttrString(selectAttrs, '')}>${optsHtml}</select>`;
|
||||
const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim());
|
||||
return `<div ${forgeAttrString(wrapperAttrs, style)}><div class="${selectWrapClass}">${selectHtml}</div></div>`;
|
||||
}
|
||||
|
||||
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
var content = forgeApplyCtx(el.content || "", ctx) || "Groupe de champs";
|
||||
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
return '<fieldset ' + forgeAttrString(attrs, style) + '><legend>' + forgeHtmlEscape(content) + '</legend>' + childHtml + '</fieldset>';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const content = forgeApplyCtx(el.content || '', ctx) || 'Groupe de champs';
|
||||
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
return `<fieldset ${forgeAttrString(attrs, style)}><legend>${forgeHtmlEscape(content)}</legend>${childHtml}</fieldset>`;
|
||||
}
|
||||
|
||||
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var headerRow = ["1", "true", "on"].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
var rows = content.split("\n").filter(function (r) { return r.trim(); });
|
||||
var htmlRows = rows.map(function (r, i) {
|
||||
var cells = r.split(";");
|
||||
var cellTag = (headerRow && i === 0) ? "th" : "td";
|
||||
var cellsHtml = cells.map(function (c) {
|
||||
return "<" + cellTag + ">" + forgeHtmlEscape(c.trim()) + "</" + cellTag + ">";
|
||||
}).join("");
|
||||
return "<tr>" + cellsHtml + "</tr>";
|
||||
}).join("");
|
||||
return '<table ' + forgeAttrString(attrs, style) + '>' + htmlRows + '</table>';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const rows = content.split('\n').filter((r) => r.trim());
|
||||
const htmlRows = rows.map((r, i) => {
|
||||
const cells = r.split(';');
|
||||
const cellTag = (headerRow && i === 0) ? 'th' : 'td';
|
||||
const cellsHtml = cells.map((c) => `<${cellTag}>${forgeHtmlEscape(c.trim())}</${cellTag}>`).join('');
|
||||
return `<tr>${cellsHtml}</tr>`;
|
||||
}).join('');
|
||||
return `<table ${forgeAttrString(attrs, style)}>${htmlRows}</table>`;
|
||||
}
|
||||
|
||||
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
@@ -67,12 +66,12 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
|
||||
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
|
||||
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
|
||||
var rawAttrs = el.attributes || {};
|
||||
var iconSlug = rawAttrs._icon_slug || "star";
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var existingClass = attrs.class || "";
|
||||
const rawAttrs = el.attributes || {};
|
||||
const iconSlug = rawAttrs._icon_slug || 'star';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const existingClass = attrs.class || '';
|
||||
delete attrs.class;
|
||||
attrs.class = (existingClass + " icon-svg").trim();
|
||||
attrs.class = (`${existingClass} icon-svg`).trim();
|
||||
// data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
|
||||
// publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
|
||||
// chemin de fichier, même relatif : Firefox traite chaque ressource
|
||||
@@ -84,49 +83,50 @@ 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.
|
||||
var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg");
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
|
||||
const iconUrl = (gameData.icon_data_uris && 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
|
||||
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
|
||||
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
|
||||
if (!("aria-hidden" in attrs)) attrs["aria-hidden"] = "true";
|
||||
return "<i " + forgeAttrString(attrs, style) + "></i>";
|
||||
if (!('aria-hidden' in attrs)) attrs['aria-hidden'] = 'true';
|
||||
return `<i ${forgeAttrString(attrs, style)}></i>`;
|
||||
}
|
||||
|
||||
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var tabsExtraClasses = attrs.class || "";
|
||||
const tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const tabsExtraClasses = attrs.class || '';
|
||||
delete attrs.class;
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; flex-direction:column;";
|
||||
var groupId = forgeAutoId(el);
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:column;`;
|
||||
const groupId = forgeAutoId(el);
|
||||
|
||||
if (!tabs.length) {
|
||||
return '<div ' + forgeAttrString(attrs, style) + '><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>';
|
||||
return `<div ${forgeAttrString(attrs, style)}><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>`;
|
||||
}
|
||||
|
||||
var flexDir = forgeParentFlexDirection(el);
|
||||
var barItems = [], panels = [];
|
||||
tabs.forEach(function (tab, i) {
|
||||
var label = tab.name || ("Onglet " + (i + 1));
|
||||
var active = i === 0;
|
||||
var tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
||||
var targetId = "tabpanel-" + tab.id;
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
const barItems = []; const
|
||||
panels = [];
|
||||
tabs.forEach((tab, i) => {
|
||||
const label = tab.name || (`Onglet ${i + 1}`);
|
||||
const active = i === 0;
|
||||
const tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
||||
const targetId = `tabpanel-${tab.id}`;
|
||||
barItems.push(
|
||||
'<li class="' + (active ? "is-active" : "") + '" data-tab-btn="' + groupId + '" data-tab-target="' + targetId +
|
||||
'" onclick="forgeShowTab(this)"><a>' + forgeHtmlEscape(String(label)) + '</a></li>'
|
||||
`<li class="${active ? 'is-active' : ''}" data-tab-btn="${groupId}" data-tab-target="${targetId
|
||||
}" onclick="forgeShowTab(this)"><a>${forgeHtmlEscape(String(label))}</a></li>`,
|
||||
);
|
||||
var hiddenStyle = active ? "" : ' style="display:none;"';
|
||||
panels.push('<div class="forgeTabPanel" id="' + targetId + '" data-tab-group="' + groupId + '"' + hiddenStyle + '>' + tabHtml + '</div>');
|
||||
const hiddenStyle = active ? '' : ' style="display:none;"';
|
||||
panels.push(`<div class="forgeTabPanel" id="${targetId}" data-tab-group="${groupId}"${hiddenStyle}>${tabHtml}</div>`);
|
||||
});
|
||||
|
||||
var tabsClass = forgeHtmlEscape(("tabs " + tabsExtraClasses).trim());
|
||||
const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim());
|
||||
return (
|
||||
'<div ' + forgeAttrString(attrs, style) + '>' +
|
||||
'<div class="' + tabsClass + '"><ul>' + barItems.join("") + '</ul></div>' +
|
||||
'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">' + panels.join("") + '</div>' +
|
||||
'</div>'
|
||||
`<div ${forgeAttrString(attrs, style)}>`
|
||||
+ `<div class="${tabsClass}"><ul>${barItems.join('')}</ul></div>`
|
||||
+ `<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">${panels.join('')}</div>`
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,27 +136,32 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
|
||||
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
|
||||
// rendu côté client.
|
||||
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs["data-personnage"] = "1";
|
||||
var animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
var idleFrame = frames && frames.length ? frames[0] : "";
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs['data-personnage'] = '1';
|
||||
const animations = gameData.personnage_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] : '';
|
||||
if (idleFrame) attrs.src = idleFrame;
|
||||
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
||||
// décoratif sur le widget "image" générique.
|
||||
if (!("alt" in attrs)) attrs.alt = "";
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
return "<img " + forgeAttrString(attrs, style) + ">";
|
||||
if (!('alt' in attrs)) attrs.alt = '';
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
return `<img ${forgeAttrString(attrs, style)}>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable,
|
||||
forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage,
|
||||
forgeRenderCheckboxOrRadio,
|
||||
forgeRenderSelect,
|
||||
forgeRenderFieldset,
|
||||
forgeRenderTable,
|
||||
forgeRenderIcone,
|
||||
forgeRenderOnglets,
|
||||
forgeRenderPersonnage,
|
||||
};
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeVisibleAttrs", "forgeStyleString", "forgeAttrString", "forgeApplyCtx", "forgeHtmlEscape",
|
||||
"forgeAutoId", "forgeParentFlexDirection", "forgeRenderChildren", "forgeRenderElementHtml"].forEach(function (name) {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeVisibleAttrs', 'forgeStyleString', 'forgeAttrString', 'forgeApplyCtx', 'forgeHtmlEscape',
|
||||
'forgeAutoId', 'forgeParentFlexDirection', 'forgeRenderChildren', 'forgeRenderElementHtml'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,68 +16,73 @@ function forgeScormFindApi(win, triesLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var forgeScormApi = null;
|
||||
var forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
|
||||
let forgeScormApi = null;
|
||||
let forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
|
||||
|
||||
function forgeScormInit() {
|
||||
forgeScormApi = forgeScormFindApi(window, 10);
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
forgeScormApi.LMSInitialize('');
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
|
||||
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
|
||||
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
|
||||
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
|
||||
// Omis si le jeu n'a aucune question de quiz (max=0, rien à barémer).
|
||||
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
if (maxScore) {
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.min", "0");
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.max", String(maxScore));
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.min', '0');
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.max', String(maxScore));
|
||||
}
|
||||
}
|
||||
|
||||
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> vocabulaire
|
||||
// figé cmi.core.lesson_status (SCORM 1.2) : pas de mapping 1-pour-1 pour
|
||||
// "en_cours" (SCORM n'a pas d'équivalent "en cours" hors "incomplete").
|
||||
var FORGE_TO_SCORM_STATUS = {
|
||||
non_commence: "not attempted", en_cours: "incomplete", termine: "completed",
|
||||
reussi: "passed", echoue: "failed",
|
||||
const FORGE_TO_SCORM_STATUS = {
|
||||
non_commence: 'not attempted',
|
||||
en_cours: 'incomplete',
|
||||
termine: 'completed',
|
||||
reussi: 'passed',
|
||||
echoue: 'failed',
|
||||
};
|
||||
|
||||
function forgeScormSyncScore(gameData) {
|
||||
if (!forgeScormApi || !gameData || !gameData.scoring) return;
|
||||
var score = gameData.scoring.score || 0;
|
||||
var status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || "incomplete";
|
||||
var key = score + "|" + status;
|
||||
const score = gameData.scoring.score || 0;
|
||||
const status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || 'incomplete';
|
||||
const key = `${score}|${status}`;
|
||||
if (key === forgeScormLastSync) return;
|
||||
forgeScormLastSync = key;
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.raw", String(score));
|
||||
forgeScormApi.LMSSetValue("cmi.core.lesson_status", status);
|
||||
forgeScormApi.LMSCommit("");
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.raw', String(score));
|
||||
forgeScormApi.LMSSetValue('cmi.core.lesson_status', status);
|
||||
forgeScormApi.LMSCommit('');
|
||||
}
|
||||
|
||||
function forgeScormFinish() {
|
||||
if (!forgeScormApi) return;
|
||||
forgeScormApi.LMSFinish("");
|
||||
forgeScormApi.LMSFinish('');
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", forgeScormInit);
|
||||
window.addEventListener("beforeunload", forgeScormFinish);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', forgeScormInit);
|
||||
window.addEventListener('beforeunload', forgeScormFinish);
|
||||
// gameData est déjà en mémoire (window.FORGE_OFFLINE) — pas de fetch à
|
||||
// attendre, un intervalle court suffit à refléter score/statut sans
|
||||
// devoir faire dépendre bindings.js de ce fichier optionnel.
|
||||
var forgeScormSyncTimer = setInterval(function () {
|
||||
if (typeof gameData !== "undefined") forgeScormSyncScore(gameData);
|
||||
const forgeScormSyncTimer = setInterval(() => {
|
||||
if (typeof gameData !== 'undefined') forgeScormSyncScore(gameData);
|
||||
}, 2000);
|
||||
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
|
||||
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
|
||||
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais.
|
||||
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === "function") {
|
||||
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === 'function') {
|
||||
forgeScormSyncTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS };
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,21 +24,21 @@ function forgeScorm2004FindApi(win, triesLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var forgeScorm2004Api = null;
|
||||
var forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
|
||||
let forgeScorm2004Api = null;
|
||||
let forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
|
||||
|
||||
function forgeScorm2004Init() {
|
||||
forgeScorm2004Api = forgeScorm2004FindApi(window, 10);
|
||||
if (!forgeScorm2004Api) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScorm2004Api.Initialize("");
|
||||
forgeScorm2004Api.Initialize('');
|
||||
// cmi.score.min/max (voir static/js/play/offline/scorm-api.js pour le
|
||||
// même réglage côté 1.2) : cmi.score.scaled (forgeScorm2004SyncScore)
|
||||
// couvre déjà le pourcentage correctement, mais min/max reste utile
|
||||
// pour tout LMS qui affiche plutôt raw/max.
|
||||
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
if (maxScore) {
|
||||
forgeScorm2004Api.SetValue("cmi.score.min", "0");
|
||||
forgeScorm2004Api.SetValue("cmi.score.max", String(maxScore));
|
||||
forgeScorm2004Api.SetValue('cmi.score.min', '0');
|
||||
forgeScorm2004Api.SetValue('cmi.score.max', String(maxScore));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,33 +46,33 @@ function forgeScorm2004Init() {
|
||||
// (completion_status, success_status) SCORM 2004 — contrairement à la
|
||||
// 1.2, "echoue" reste "completed" (l'apprenant a fini la formation) avec
|
||||
// success_status="failed", au lieu de retomber en "incomplete".
|
||||
var FORGE_TO_SCORM2004_STATUS = {
|
||||
non_commence: { completion: "not attempted", success: "unknown" },
|
||||
en_cours: { completion: "incomplete", success: "unknown" },
|
||||
termine: { completion: "completed", success: "unknown" },
|
||||
reussi: { completion: "completed", success: "passed" },
|
||||
echoue: { completion: "completed", success: "failed" },
|
||||
const FORGE_TO_SCORM2004_STATUS = {
|
||||
non_commence: { completion: 'not attempted', success: 'unknown' },
|
||||
en_cours: { completion: 'incomplete', success: 'unknown' },
|
||||
termine: { completion: 'completed', success: 'unknown' },
|
||||
reussi: { completion: 'completed', success: 'passed' },
|
||||
echoue: { completion: 'completed', success: 'failed' },
|
||||
};
|
||||
|
||||
function forgeScorm2004SyncScore(gameData) {
|
||||
if (!forgeScorm2004Api || !gameData || !gameData.scoring) return;
|
||||
var score = gameData.scoring.score || 0;
|
||||
var statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
|
||||
var key = score + "|" + statuses.completion + "|" + statuses.success;
|
||||
const score = gameData.scoring.score || 0;
|
||||
const statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
|
||||
const key = `${score}|${statuses.completion}|${statuses.success}`;
|
||||
if (key === forgeScorm2004LastSync) return;
|
||||
forgeScorm2004LastSync = key;
|
||||
forgeScorm2004Api.SetValue("cmi.score.raw", String(score));
|
||||
forgeScorm2004Api.SetValue('cmi.score.raw', String(score));
|
||||
// cmi.score.scaled (-1..1, ici toujours 0..1) : uniquement dérivable du
|
||||
// taux de bonnes réponses au quiz (voir dialogue-box-controller.js),
|
||||
// jamais de gameData.scoring.score qui n'est qu'un total de points sans
|
||||
// maximum connu — omis si aucune question n'a encore été répondue.
|
||||
if (typeof window !== "undefined" && window.forgeQuizTotalAnswered) {
|
||||
var scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
|
||||
forgeScorm2004Api.SetValue("cmi.score.scaled", String(scaled));
|
||||
if (typeof window !== 'undefined' && window.forgeQuizTotalAnswered) {
|
||||
const scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
|
||||
forgeScorm2004Api.SetValue('cmi.score.scaled', String(scaled));
|
||||
}
|
||||
forgeScorm2004Api.SetValue("cmi.completion_status", statuses.completion);
|
||||
forgeScorm2004Api.SetValue("cmi.success_status", statuses.success);
|
||||
forgeScorm2004Api.Commit("");
|
||||
forgeScorm2004Api.SetValue('cmi.completion_status', statuses.completion);
|
||||
forgeScorm2004Api.SetValue('cmi.success_status', statuses.success);
|
||||
forgeScorm2004Api.Commit('');
|
||||
}
|
||||
|
||||
// cmi.interactions.n.* (voir dialogue-box-controller.js::forgeQuizBoxAnswer,
|
||||
@@ -90,47 +90,60 @@ function forgeScorm2004SyncScore(gameData) {
|
||||
// silencieusement (SetValue échoue sans erreur visible : le champ reste
|
||||
// vide). "…T19:49:39" (sans les millisecondes ni le "Z") passe partout.
|
||||
function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
function forgeScorm2004NotifyQuestionAnswered(
|
||||
dialogueId,
|
||||
objectName,
|
||||
questionIndex,
|
||||
questionText,
|
||||
correct,
|
||||
choiceIndex,
|
||||
correctIndex,
|
||||
) {
|
||||
if (!forgeScorm2004Api) return;
|
||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||
if (isNaN(n)) n = 0;
|
||||
var prefix = "cmi.interactions." + n + ".";
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "type", "choice");
|
||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
||||
forgeScorm2004Api.SetValue(prefix + "learner_response", String(choiceIndex));
|
||||
forgeScorm2004Api.SetValue(prefix + "result", correct ? "correct" : "incorrect");
|
||||
forgeScorm2004Api.SetValue(prefix + "correct_responses.0.pattern", String(correctIndex));
|
||||
forgeScorm2004Api.Commit("");
|
||||
let n = parseInt(forgeScorm2004Api.GetValue('cmi.interactions._count'), 10);
|
||||
if (Number.isNaN(n)) n = 0;
|
||||
const prefix = `cmi.interactions.${n}.`;
|
||||
forgeScorm2004Api.SetValue(`${prefix}id`, `dialogue:${dialogueId}:question:${questionIndex}`);
|
||||
forgeScorm2004Api.SetValue(`${prefix}type`, 'choice');
|
||||
forgeScorm2004Api.SetValue(`${prefix}timestamp`, forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(`${prefix}description`, `{fr-FR}${questionText || ''}`);
|
||||
forgeScorm2004Api.SetValue(`${prefix}learner_response`, String(choiceIndex));
|
||||
forgeScorm2004Api.SetValue(`${prefix}result`, correct ? 'correct' : 'incorrect');
|
||||
forgeScorm2004Api.SetValue(`${prefix}correct_responses.0.pattern`, String(correctIndex));
|
||||
forgeScorm2004Api.Commit('');
|
||||
}
|
||||
|
||||
function forgeScorm2004Finish() {
|
||||
if (!forgeScorm2004Api) return;
|
||||
forgeScorm2004Api.Terminate("");
|
||||
forgeScorm2004Api.Terminate('');
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", forgeScorm2004Init);
|
||||
window.addEventListener("beforeunload", forgeScorm2004Finish);
|
||||
var forgeScorm2004SyncTimer = setInterval(function () {
|
||||
if (typeof gameData !== "undefined") forgeScorm2004SyncScore(gameData);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', forgeScorm2004Init);
|
||||
window.addEventListener('beforeunload', forgeScorm2004Finish);
|
||||
const forgeScorm2004SyncTimer = setInterval(() => {
|
||||
if (typeof gameData !== 'undefined') forgeScorm2004SyncScore(gameData);
|
||||
}, 2000);
|
||||
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
|
||||
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
|
||||
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais
|
||||
// (l'intervalle garde le process vivant indéfiniment).
|
||||
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === "function") {
|
||||
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === 'function') {
|
||||
forgeScorm2004SyncTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore, forgeScorm2004Finish,
|
||||
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
|
||||
forgeScorm2004FindApi,
|
||||
forgeScorm2004Init,
|
||||
forgeScorm2004SyncScore,
|
||||
forgeScorm2004Finish,
|
||||
forgeScorm2004NotifyQuestionAnswered,
|
||||
forgeScorm2004Timestamp,
|
||||
FORGE_TO_SCORM2004_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,38 +11,38 @@
|
||||
// serveur : sans ça, tous les exemplaires d'un même modèle de ligne
|
||||
// évalueraient la MÊME ligne au lieu de chacun la sienne).
|
||||
function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
|
||||
var mode = attrs._visibilite_cond_mode;
|
||||
if (mode !== "variable" && mode !== "objet") return true;
|
||||
var operator = attrs._visibilite_cond_operateur || "egal";
|
||||
var expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || "");
|
||||
const mode = attrs._visibilite_cond_mode;
|
||||
if (mode !== 'variable' && mode !== 'objet') return true;
|
||||
const operator = attrs._visibilite_cond_operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || '');
|
||||
|
||||
if (mode === "variable") {
|
||||
var varName = attrs._visibilite_cond_variable;
|
||||
if (mode === 'variable') {
|
||||
const varName = attrs._visibilite_cond_variable;
|
||||
if (!varName) return true;
|
||||
var variable = gameData.variables && gameData.variables[varName];
|
||||
const variable = gameData.variables && gameData.variables[varName];
|
||||
if (!variable) return true; // variable supprimée depuis : ne bloque jamais l'affichage
|
||||
var value = variable.value;
|
||||
var path = attrs._visibilite_cond_variable_chemin;
|
||||
let { value } = variable;
|
||||
const path = attrs._visibilite_cond_variable_chemin;
|
||||
if (path) value = forgeResolveVariablePath(value, path);
|
||||
return compareValues(value, operator, expected, variable.type || "texte");
|
||||
return compareValues(value, operator, expected, variable.type || 'texte');
|
||||
}
|
||||
|
||||
var definitionId = attrs._visibilite_cond_definition_id;
|
||||
var fieldName = attrs._visibilite_cond_champ;
|
||||
let definitionId = attrs._visibilite_cond_definition_id;
|
||||
const fieldName = attrs._visibilite_cond_champ;
|
||||
if (!definitionId || !fieldName) return true;
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) return true;
|
||||
var field = fieldsMetaForDef.find(function (f) { return f.name === fieldName; });
|
||||
var fieldType = field ? field.type : "texte";
|
||||
const field = fieldsMetaForDef.find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
|
||||
if (ctx && Object.prototype.hasOwnProperty.call(ctx, fieldName)) {
|
||||
return compareValues(ctx[fieldName], operator, expected, fieldType);
|
||||
}
|
||||
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return true; // objet sans donnée pour l'instant : ne bloque pas
|
||||
var row = rows[0]; // convention "état de partie" : une seule ligne
|
||||
const row = rows[0]; // convention "état de partie" : une seule ligne
|
||||
// row[fieldName] : gameData.data est déjà indexé par le nom D'AFFICHAGE
|
||||
// du champ (voir full_game_payload.py), jamais par sa colonne SQL.
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
@@ -50,7 +50,7 @@ function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeIsElementVisibleOffline };
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
|
||||
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
|
||||
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
|
||||
|
||||
@@ -15,15 +15,25 @@
|
||||
//
|
||||
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
|
||||
// bloquer ni ralentir la partie du joueur.
|
||||
//
|
||||
// NOSONAR S5332 (toutes occurrences "http://adlnet.gov/..." de ce fichier
|
||||
// + les 2 assertions de __tests__/xapi-client.test.js) : identifiants du
|
||||
// vocabulaire xAPI standard ADL, jamais dérérencés/appelés en réseau (de
|
||||
// simples chaînes comparées/embarquées) — le "http://" fait partie de la
|
||||
// chaîne fixée par la spec, jamais "https://" (changer casserait
|
||||
// l'interopérabilité avec n'importe quel LRS). Le VRAI endpoint réseau
|
||||
// (voir forgeXapiSendStatement plus bas) est toujours `config.endpoint`,
|
||||
// saisi par le créateur (routes/publish/xapi_settings.py), jamais un
|
||||
// littéral de ce fichier.
|
||||
|
||||
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
|
||||
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
|
||||
// génèrent rien ici (bruit inutile pour un suivi volontairement léger,
|
||||
// voir le plan).
|
||||
var FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: "http://adlnet.gov/expapi/verbs/completed" },
|
||||
reussi: { id: "http://adlnet.gov/expapi/verbs/passed" },
|
||||
echoue: { id: "http://adlnet.gov/expapi/verbs/failed" },
|
||||
const FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: 'http://adlnet.gov/expapi/verbs/completed' }, // NOSONAR S5332
|
||||
reussi: { id: 'http://adlnet.gov/expapi/verbs/passed' }, // NOSONAR S5332
|
||||
echoue: { id: 'http://adlnet.gov/expapi/verbs/failed' }, // NOSONAR S5332
|
||||
};
|
||||
|
||||
// Libellé humain par verbe — l'id xAPI (http://adlnet.gov/expapi/verbs/...)
|
||||
@@ -33,18 +43,18 @@ var FORGE_TO_XAPI_VERB = {
|
||||
// français ?" — un LRS qui préfère le français (ou n'importe quelle
|
||||
// langue ici) affichera la bonne entrée au lieu de rester bloqué sur
|
||||
// l'anglais.
|
||||
var FORGE_XAPI_VERB_DISPLAY = {
|
||||
"http://adlnet.gov/expapi/verbs/initialized": { "en-US": "initialized", "fr-FR": "initialisé" },
|
||||
"http://adlnet.gov/expapi/verbs/terminated": { "en-US": "terminated", "fr-FR": "quitté" },
|
||||
"http://adlnet.gov/expapi/verbs/completed": { "en-US": "completed", "fr-FR": "terminé" },
|
||||
"http://adlnet.gov/expapi/verbs/passed": { "en-US": "passed", "fr-FR": "réussi" },
|
||||
"http://adlnet.gov/expapi/verbs/failed": { "en-US": "failed", "fr-FR": "échoué" },
|
||||
"http://adlnet.gov/expapi/verbs/scored": { "en-US": "scored", "fr-FR": "noté" },
|
||||
"http://adlnet.gov/expapi/verbs/attempted": { "en-US": "attempted", "fr-FR": "commencé" },
|
||||
"http://adlnet.gov/expapi/verbs/answered": { "en-US": "answered", "fr-FR": "répondu" },
|
||||
const FORGE_XAPI_VERB_DISPLAY = {
|
||||
'http://adlnet.gov/expapi/verbs/initialized': { 'en-US': 'initialized', 'fr-FR': 'initialisé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/terminated': { 'en-US': 'terminated', 'fr-FR': 'quitté' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/completed': { 'en-US': 'completed', 'fr-FR': 'terminé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/passed': { 'en-US': 'passed', 'fr-FR': 'réussi' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/failed': { 'en-US': 'failed', 'fr-FR': 'échoué' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/scored': { 'en-US': 'scored', 'fr-FR': 'noté' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/attempted': { 'en-US': 'attempted', 'fr-FR': 'commencé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/answered': { 'en-US': 'answered', 'fr-FR': 'répondu' }, // NOSONAR S5332
|
||||
};
|
||||
|
||||
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
let forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
|
||||
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
|
||||
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
|
||||
@@ -52,23 +62,23 @@ var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si
|
||||
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
|
||||
function forgeXapiActor() {
|
||||
try {
|
||||
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
|
||||
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
|
||||
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
|
||||
if (typeof forgeScormApi !== 'undefined' && forgeScormApi) {
|
||||
const name = forgeScormApi.LMSGetValue('cmi.core.student_name');
|
||||
const id = forgeScormApi.LMSGetValue('cmi.core.student_id');
|
||||
if (id) {
|
||||
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
|
||||
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
|
||||
}
|
||||
}
|
||||
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
|
||||
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
|
||||
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
|
||||
}
|
||||
|
||||
function forgeXapiObject() {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
objectType: 'Activity',
|
||||
id: config.activity_id,
|
||||
definition: { name: { "fr-FR": config.activity_name } },
|
||||
definition: { name: { 'fr-FR': config.activity_name } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,24 +90,24 @@ function forgeXapiObject() {
|
||||
// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de
|
||||
// "quête" pour le porter.
|
||||
function forgeXapiDialogueObject(dialogueId, objectName) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
objectType: 'Activity',
|
||||
id: `${config.activity_id}:dialogue:${dialogueId}`,
|
||||
definition: { name: { 'fr-FR': objectName || 'Dialogue' }, type: 'http://adlnet.gov/expapi/activities/simulation' }, // NOSONAR S5332
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
objectType: 'Activity',
|
||||
id: `${config.activity_id}:dialogue:${dialogueId}:question:${questionIndex}`,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
name: { 'fr-FR': `Question ${questionIndex + 1} — ${objectName || 'Dialogue'}` },
|
||||
description: { 'fr-FR': questionText || '' },
|
||||
type: 'http://adlnet.gov/expapi/activities/cmi.interaction', // NOSONAR S5332
|
||||
interactionType: 'choice',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -110,27 +120,27 @@ function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, question
|
||||
// (forgeXapiObject), ou une sous-activité quête/question
|
||||
// (forgeXapiQuestObject/forgeXapiQuestionObject) pour un suivi plus fin.
|
||||
function forgeXapiSendStatement(verbId, result, object) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
if (!config || !config.endpoint) return;
|
||||
var statement = {
|
||||
const statement = {
|
||||
actor: forgeXapiActor(),
|
||||
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { "en-US": verbId } },
|
||||
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { 'en-US': verbId } },
|
||||
object: object || forgeXapiObject(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (result) statement.result = result;
|
||||
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
|
||||
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`;
|
||||
try {
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Experience-API-Version": "1.0.3",
|
||||
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
|
||||
'Content-Type': 'application/json',
|
||||
'X-Experience-API-Version': '1.0.3',
|
||||
Authorization: `Basic ${btoa(`${config.login}:${config.password}`)}`,
|
||||
},
|
||||
body: JSON.stringify(statement),
|
||||
}).catch(function (err) {
|
||||
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
|
||||
}).catch((err) => {
|
||||
console.warn('xAPI: envoi du statement échoué (LRS injoignable ?)', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("xAPI: impossible d'envoyer le statement", err);
|
||||
@@ -143,8 +153,8 @@ function forgeXapiSendStatement(verbId, result, object) {
|
||||
function forgeXapiNotifyScoreChanged(newScore) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/scored",
|
||||
{ score: { raw: newScore } }
|
||||
'http://adlnet.gov/expapi/verbs/scored', // NOSONAR S5332
|
||||
{ score: { raw: newScore } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,44 +166,51 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
||||
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/completed",
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
'http://adlnet.gov/expapi/verbs/completed', // NOSONAR S5332
|
||||
{ completion: true },
|
||||
forgeXapiDialogueObject(dialogueId, objectName),
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/answered",
|
||||
'http://adlnet.gov/expapi/verbs/answered', // NOSONAR S5332
|
||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText),
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyStatusChanged(newStatus) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
var verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
const verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
if (!verb || newStatus === forgeXapiLastStatusSent) return;
|
||||
forgeXapiLastStatusSent = newStatus;
|
||||
forgeXapiSendStatement(verb.id, {
|
||||
completion: true,
|
||||
success: newStatus !== "echoue",
|
||||
success: newStatus !== 'echoue',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized");
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/initialized'); // NOSONAR S5332
|
||||
});
|
||||
window.addEventListener("beforeunload", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated");
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/terminated'); // NOSONAR S5332
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiSendStatement,
|
||||
forgeXapiNotifyScoreChanged,
|
||||
forgeXapiNotifyStatusChanged,
|
||||
FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyDialogueCompleted,
|
||||
forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject,
|
||||
forgeXapiQuestionObject,
|
||||
FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
// mécanique que "Modifier un objet de scène"/"Jouer une animation",
|
||||
// jamais dupliquée.
|
||||
|
||||
var activePersonnageControllerInterval = null;
|
||||
var activePersonnageInteractListener = null;
|
||||
let activePersonnageControllerInterval = null;
|
||||
let activePersonnageInteractListener = null;
|
||||
|
||||
// Un seul rythme pour TOUTES les poses (marche, idle, interagir, touches
|
||||
// supplémentaires) — les désynchroniser (ex. idle plus lent que walk)
|
||||
// n'a aucune justification et se voit immédiatement au jeu (bug signalé
|
||||
// par l'utilisateur).
|
||||
var FORGE_PERSONNAGE_FPS = 8;
|
||||
const FORGE_PERSONNAGE_FPS = 8;
|
||||
|
||||
function forgePersonnageAnimExists(elementId, animName) {
|
||||
var byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
const byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
||||
}
|
||||
|
||||
@@ -48,15 +48,16 @@ function forgePersonnageIdleAnim(elementId) {
|
||||
// borne un déplacement au monde entier, pas seulement au petit cadre
|
||||
// visible de la scène.
|
||||
function forgeUpdateSceneWorldSize(screenData) {
|
||||
var width = screenData.scene_width, height = screenData.scene_height;
|
||||
(screenData.elements || []).forEach(function (o) {
|
||||
let width = screenData.scene_width; let
|
||||
height = screenData.scene_height;
|
||||
(screenData.elements || []).forEach((o) => {
|
||||
if (o.kind !== 'fond') return;
|
||||
width = Math.max(width, (o.x || 0) + (o.width || 0));
|
||||
height = Math.max(height, (o.y || 0) + (o.height || 0));
|
||||
});
|
||||
screenData.world_width = width;
|
||||
screenData.world_height = height;
|
||||
return { width: width, height: height };
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// Caméra qui suit le personnage (le premier objet kind="personnage"
|
||||
@@ -68,22 +69,32 @@ function forgeUpdateSceneWorldSize(screenData) {
|
||||
// dépasse pas la scène — c'est ce qui garde le comportement identique à
|
||||
// avant pour tout jeu sans fond plus grand que sa scène.
|
||||
function forgeUpdateSceneCamera(screenData) {
|
||||
var worldEl = document.querySelector('.playScreen[data-screen-id="' + screenData.id + '"] .sceneWorld');
|
||||
const worldEl = document.querySelector(`.playScreen[data-screen-id="${screenData.id}"] .sceneWorld`);
|
||||
if (!worldEl) return;
|
||||
var world = forgeUpdateSceneWorldSize(screenData);
|
||||
worldEl.style.width = world.width + 'px';
|
||||
worldEl.style.height = world.height + 'px';
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
worldEl.style.width = `${world.width}px`;
|
||||
worldEl.style.height = `${world.height}px`;
|
||||
if (world.width <= screenData.scene_width && world.height <= screenData.scene_height) {
|
||||
worldEl.style.transform = '';
|
||||
return;
|
||||
}
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
|
||||
var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2;
|
||||
var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2;
|
||||
var offsetX = Math.min(Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width));
|
||||
var offsetY = Math.min(Math.max(py - screenData.scene_height / 2, 0), Math.max(0, world.height - screenData.scene_height));
|
||||
worldEl.style.transform = 'translate(' + (-offsetX) + 'px, ' + (-offsetY) + 'px)';
|
||||
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
|
||||
: world.width / 2;
|
||||
const py = playerEl
|
||||
? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2
|
||||
: world.height / 2;
|
||||
const offsetX = Math.min(
|
||||
Math.max(px - screenData.scene_width / 2, 0),
|
||||
Math.max(0, world.width - screenData.scene_width),
|
||||
);
|
||||
const offsetY = Math.min(
|
||||
Math.max(py - screenData.scene_height / 2, 0),
|
||||
Math.max(0, world.height - screenData.scene_height),
|
||||
);
|
||||
worldEl.style.transform = `translate(${-offsetX}px, ${-offsetY}px)`;
|
||||
}
|
||||
|
||||
function forgeStopPersonnageControllers() {
|
||||
@@ -103,7 +114,9 @@ function forgeStopPersonnageControllers() {
|
||||
// (conditions.js, déjà chargé avant ce fichier dans templates/play.html),
|
||||
// la MÊME formule que la condition de collision de flow (elementsOverlap).
|
||||
function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
||||
return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision);
|
||||
return forgeCollisionRectFromBox({
|
||||
left, top, width, height,
|
||||
}, collision);
|
||||
}
|
||||
|
||||
// Tout objet de scène potentiellement "solide" pour le déplacement au
|
||||
@@ -112,9 +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(function (o) {
|
||||
return 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 && o.collision.enabled === false));
|
||||
}
|
||||
|
||||
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
|
||||
@@ -130,7 +141,7 @@ function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height
|
||||
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
||||
for (let i = 0; i < obstacles.length; i++) {
|
||||
const other = obstacles[i];
|
||||
const otherEl = document.querySelector('[data-object-id="' + other.id + '"]');
|
||||
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;
|
||||
@@ -146,16 +157,17 @@ function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height
|
||||
// (triggers.js), pour un déplacement perçu comme continu.
|
||||
function forgeRunPersonnageControllerTick(screenData) {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
(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;
|
||||
var cmd = obj.personnage_commandes;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const cmd = obj.personnage_commandes;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
|
||||
var rawDx = 0, rawDy = 0;
|
||||
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;
|
||||
@@ -168,17 +180,18 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
// 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.
|
||||
var curLeft = parseFloat(targetEl.style.left) || 0;
|
||||
var curTop = parseFloat(targetEl.style.top) || 0;
|
||||
var boxWidth = parseFloat(targetEl.style.width) || 0;
|
||||
var boxHeight = parseFloat(targetEl.style.height) || 0;
|
||||
var dx = rawDx, dy = rawDy;
|
||||
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) {
|
||||
var candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
var candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
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
|
||||
@@ -191,14 +204,24 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
// animations sont lentes à côté de la marche" — aucune raison réelle
|
||||
// de les désynchroniser).
|
||||
if (dx === 0 && dy === 0) {
|
||||
var idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||
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);
|
||||
var moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +232,7 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
// (sauter, attaquer, courir...) sans ajouter un champ fixe par pose.
|
||||
function forgePersonnageActionAnim(cmd, key) {
|
||||
if (cmd.interagir === key) return 'interact';
|
||||
var extraMatch = (cmd.extra || []).find(function (e) { return e.touche === key; });
|
||||
const extraMatch = (cmd.extra || []).find((e) => e.touche === key);
|
||||
return extraMatch ? extraMatch.animation : null;
|
||||
}
|
||||
|
||||
@@ -218,13 +241,16 @@ function forgePersonnageActionAnim(cmd, key) {
|
||||
// attaquer...), jamais un mouvement continu.
|
||||
function forgeStartPersonnageInteractListener(screenData) {
|
||||
activePersonnageInteractListener = function (ev) {
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
|
||||
const animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
|
||||
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false }),
|
||||
);
|
||||
});
|
||||
};
|
||||
window.addEventListener('keydown', activePersonnageInteractListener);
|
||||
@@ -236,9 +262,9 @@ function forgeStartPersonnageInteractListener(screenData) {
|
||||
function forgeStartPersonnageControllers(screenData) {
|
||||
forgeStopPersonnageControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasFond = elements.some(function (o) { return o.kind === 'fond'; });
|
||||
const elements = screenData.elements || [];
|
||||
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasFond = elements.some((o) => o.kind === 'fond');
|
||||
// Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms
|
||||
// plus tard) pour éviter un flash d'une frame à la taille/caméra par
|
||||
// défaut. Un fond seul (sans personnage) n'a besoin que de CET appel
|
||||
@@ -246,7 +272,7 @@ function forgeStartPersonnageControllers(screenData) {
|
||||
// tick régulier pour le déplacement continu au clavier.
|
||||
if (hasFond) forgeUpdateSceneCamera(screenData);
|
||||
if (!hasPersonnage) return;
|
||||
activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50);
|
||||
activePersonnageControllerInterval = setInterval(() => { forgeRunPersonnageControllerTick(screenData); }, 50);
|
||||
forgeStartPersonnageInteractListener(screenData);
|
||||
}
|
||||
|
||||
@@ -255,10 +281,19 @@ function forgeStartPersonnageControllers(screenData) {
|
||||
// client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
||||
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
||||
forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
||||
forgeObjectCollisionRectAt, forgeSolidObstacles, forgeWouldCollide,
|
||||
forgePersonnageAnimExists,
|
||||
forgePersonnageMovementAnim,
|
||||
forgePersonnageIdleAnim,
|
||||
forgePersonnageActionAnim,
|
||||
forgeRunPersonnageControllerTick,
|
||||
forgeStartPersonnageControllers,
|
||||
forgeStopPersonnageControllers,
|
||||
forgeStartPersonnageInteractListener,
|
||||
forgeUpdateSceneWorldSize,
|
||||
forgeUpdateSceneCamera,
|
||||
forgeObjectCollisionRectAt,
|
||||
forgeSolidObstacles,
|
||||
forgeWouldCollide,
|
||||
};
|
||||
// node:test charge ce fichier isolément (voir __tests__/) — jamais un
|
||||
// <script> séparé en navigateur, où conditions.js est déjà chargé avant
|
||||
|
||||
@@ -20,22 +20,22 @@
|
||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||
if (!widget) return;
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
if (!quizState) return;
|
||||
var dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
if (!dialogueState) return;
|
||||
function isQuestion(line) { return line && line.type === 'question'; }
|
||||
var total = dialogueState.lines.filter(isQuestion).length;
|
||||
const total = dialogueState.lines.filter(isQuestion).length;
|
||||
if (total === 0) return;
|
||||
var current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = 'Question ' + current + '/' + total;
|
||||
var progressEl = widget.querySelector(progressSelector);
|
||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = `Question ${current}/${total}`;
|
||||
const progressEl = widget.querySelector(progressSelector);
|
||||
if (progressEl) {
|
||||
var bars = [];
|
||||
for (var i = 1; i <= total; i++) {
|
||||
bars.push('<i class="' + (i < current ? 'is-done' : (i === current ? 'is-current' : '')) + '"></i>');
|
||||
const bars = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
bars.push(`<i class="${i < current ? 'is-done' : (i === current ? 'is-current' : '')}"></i>`);
|
||||
}
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
@@ -56,7 +56,7 @@ function forgeQuizClassiqueOnShow(widget) {
|
||||
// .classiqueContent (pas la racine du widget, voir
|
||||
// render_quiz_box_page_classique) plutôt que sur le widget lui-même.
|
||||
if (widget) {
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
forgeQuizProgressOnShow(widget, '[data-classique-role="progress"]');
|
||||
@@ -76,26 +76,25 @@ function forgeQuizClassiqueOnShow(widget) {
|
||||
// forgeQuizResultQuit, dialogue-box-controller.js.
|
||||
function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
var zone = widget.querySelector('[data-classique-role="result"]');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
const zone = widget.querySelector('[data-classique-role="result"]');
|
||||
if (!content || !zone) return;
|
||||
var total = (result && result.totalCount) || 0;
|
||||
var correct = (result && result.correctCount) || 0;
|
||||
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && 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;
|
||||
var passed = pct >= threshold;
|
||||
var titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
var title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
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>' +
|
||||
'<div class="classiqueResultBadge ' + (passed ? 'pass' : 'fail') + '">' +
|
||||
(passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') +
|
||||
'</div>' +
|
||||
'<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">✕ Quitter le quiz</button>';
|
||||
const passed = pct >= threshold;
|
||||
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
const title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
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>`
|
||||
+ `<div class="classiqueResultBadge ${passed ? 'pass' : 'fail'}">${
|
||||
passed ? '✓ Module validé' : `✕ Module non validé — seuil ${threshold}%`
|
||||
}</div>`
|
||||
+ `<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(${quizObjectId})">✕ Quitter le quiz</button>`;
|
||||
content.classList.add('is-showing-result');
|
||||
}
|
||||
|
||||
@@ -111,32 +110,31 @@ function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
// visuellement se lire comme entièrement complété.
|
||||
function forgeQuizMangaOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
var zone = widget.querySelector('[data-manga-role="result"]');
|
||||
const zone = widget.querySelector('[data-manga-role="result"]');
|
||||
if (!zone) return;
|
||||
var total = (result && result.totalCount) || 0;
|
||||
var correct = (result && result.correctCount) || 0;
|
||||
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && 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;
|
||||
var passed = pct >= threshold;
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML =
|
||||
'<div class="mangaResultCard">' +
|
||||
'<span class="mangaResultTitle">Fin du quiz</span>' +
|
||||
'<p class="mangaResultScore">Score final : ' + correct + ' / ' + total + '</p>' +
|
||||
'<div class="mangaResultBadge ' + (passed ? 'pass' : 'fail') + '">' + (passed ? '合格' : '不合格') + '</div>' +
|
||||
'<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">Quitter le quiz</button></div>' +
|
||||
'</div>';
|
||||
var progressEl = widget.querySelector('[data-manga-role="progress"]');
|
||||
const passed = pct >= threshold;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML = '<div class="mangaResultCard">'
|
||||
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
||||
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
||||
+ `<div class="mangaResultBadge ${passed ? 'pass' : 'fail'}">${passed ? '合格' : '不合格'}</div>`
|
||||
+ `<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(${quizObjectId})">Quitter le quiz</button></div>`
|
||||
+ '</div>';
|
||||
const progressEl = widget.querySelector('[data-manga-role="progress"]');
|
||||
if (progressEl && total > 0) {
|
||||
var bars = [];
|
||||
for (var i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
|
||||
const bars = [];
|
||||
for (let i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
widget.classList.add('is-showing-result');
|
||||
}
|
||||
|
||||
var forgeQuizTemplateEffects = {
|
||||
const forgeQuizTemplateEffects = {
|
||||
// Modèles "boîte de dialogue" (voir screens/rendering/quiz_box_config.py
|
||||
// ::QUIZ_BOX_DIALOG_TEMPLATES) — "Verre dépoli"/"Néon"/"Minimal"/
|
||||
// "Ludique" ont été retirés (demande explicite : "supprime tous les
|
||||
@@ -159,7 +157,10 @@ var forgeQuizTemplateEffects = {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeQuizTemplateEffects,
|
||||
forgeQuizProgressOnShow, forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
|
||||
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
|
||||
forgeQuizProgressOnShow,
|
||||
forgeQuizMangaOnShow,
|
||||
forgeQuizMangaOnQuizComplete,
|
||||
forgeQuizClassiqueOnShow,
|
||||
forgeQuizClassiqueOnQuizComplete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
// voir surbrillance/visibilite/indication).
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
var rules = screenData.screen_triggers;
|
||||
const rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
rules.forEach(function (rule) {
|
||||
rules.forEach((rule) => {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
});
|
||||
|
||||
+51
-49
@@ -8,12 +8,12 @@
|
||||
// sans aller-retour serveur — même fonction que dans screen_edit.html,
|
||||
// pour un comportement identique éditeur/jeu.
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
const groupId = btn.dataset.tabBtn;
|
||||
const targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll(`[data-tab-btn="${groupId}"]`).forEach((b) => {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
document.querySelectorAll(`[data-tab-group="${groupId}"]`).forEach((p) => {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
@@ -31,8 +31,8 @@ function showScreen(id) {
|
||||
// visible" du même clic...). Aller vers un écran DIFFÉRENT continue de
|
||||
// tout rejouer normalement, comme un vrai premier affichage.
|
||||
if (id === window.currentScreenId) return;
|
||||
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
|
||||
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
|
||||
document.querySelectorAll('.playScreen').forEach((s) => s.style.display = 'none');
|
||||
const el = document.querySelector(`.playScreen[data-screen-id="${id}"]`);
|
||||
if (el) {
|
||||
el.style.display = 'block';
|
||||
window.currentScreenId = id;
|
||||
@@ -52,15 +52,15 @@ function showScreen(id) {
|
||||
// Déplacement/animation automatiques de tout personnage posé sur cet
|
||||
// écran (voir static/js/play/personnage-controller.js) — n'a d'effet
|
||||
// que sur un écran "jeu_2d" contenant au moins un objet kind="personnage".
|
||||
forgeStartPersonnageControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
forgeStartPersonnageControllers(screensData.find((s) => s.id === id));
|
||||
// Règles "à la collision/dans un périmètre -> action" (voir
|
||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
forgeStartCollisionRuleControllers(screensData.find((s) => s.id === id));
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir screen-triggers-controller.js,
|
||||
// screens/rendering/screen_triggers.py) — narration/cinématique sans
|
||||
// objet requis, exécutée à chaque affichage de CET écran.
|
||||
forgeRunScreenTriggers(screensData.find(function(s) { return s.id === id; }));
|
||||
forgeRunScreenTriggers(screensData.find((s) => s.id === id));
|
||||
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||
@@ -74,28 +74,29 @@ function showScreen(id) {
|
||||
// arrêtée au changement d'écran — un seul Audio actif à la fois (pas un
|
||||
// tableau comme activeTimerTriggerIntervals dans triggers.js, une
|
||||
// musique de fond ne se cumule jamais avec une autre).
|
||||
var currentBackgroundAudio = null;
|
||||
let currentBackgroundAudio = null;
|
||||
|
||||
function runScreenBackgroundMusic(screenId) {
|
||||
if (currentBackgroundAudio) {
|
||||
currentBackgroundAudio.pause();
|
||||
currentBackgroundAudio = null;
|
||||
}
|
||||
var screenData = screensData.find(function(s) { return String(s.id) === String(screenId); });
|
||||
var url = screenData && screenData.background_music_url;
|
||||
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
||||
const url = screenData && screenData.background_music_url;
|
||||
if (!url) return;
|
||||
currentBackgroundAudio = new Audio(url);
|
||||
currentBackgroundAudio.loop = true;
|
||||
currentBackgroundAudio.play().catch(function() {});
|
||||
currentBackgroundAudio.play().catch(() => {});
|
||||
}
|
||||
|
||||
// Voir le commentaire sur #playFrame dans le <style> de play.html : la
|
||||
// zone de jeu garde toujours la proportion de l'écran affiché, quelle
|
||||
// que soit la forme réelle de la fenêtre du joueur.
|
||||
function applyScreenAspect(aspect) {
|
||||
var parts = String(aspect).split('/');
|
||||
var w = parseFloat(parts[0]) || 9, h = parseFloat(parts[1]) || 16;
|
||||
var frame = document.getElementById('playFrame');
|
||||
const parts = String(aspect).split('/');
|
||||
const w = parseFloat(parts[0]) || 9; const
|
||||
h = parseFloat(parts[1]) || 16;
|
||||
const frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.style.setProperty('--ratio-w', w);
|
||||
frame.style.setProperty('--ratio-h', h);
|
||||
@@ -109,14 +110,14 @@ function applyScreenAspect(aspect) {
|
||||
// et sa taille en ratio fluide (écran "document") À CHAQUE showScreen(),
|
||||
// pas une seule fois au chargement.
|
||||
function applyScreenKind(kind) {
|
||||
var frame = document.getElementById('playFrame');
|
||||
const frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.classList.toggle('sceneMode', kind === 'jeu_2d');
|
||||
}
|
||||
}
|
||||
|
||||
function screenIndexById(id) {
|
||||
return screensData.findIndex(s => s.id === id);
|
||||
return screensData.findIndex((s) => s.id === id);
|
||||
}
|
||||
|
||||
// ---------- Timeline d'animation (menu "Ajouter un élément" → panneau
|
||||
@@ -126,7 +127,7 @@ function screenIndexById(id) {
|
||||
// Deux types de clip : "animate_css" pose une classe de la bibliothèque
|
||||
// Animate.css (voir animate_css_catalog.py), "custom" joue des
|
||||
// images-clés propres au jeu (voir injectCustomKeyframes ci-dessous). ----------
|
||||
var pendingAnimationTimers = [];
|
||||
let pendingAnimationTimers = [];
|
||||
|
||||
function injectCustomKeyframes() {
|
||||
// Construit UNE FOIS toutes les @keyframes "personnalisées" de tous les
|
||||
@@ -134,20 +135,20 @@ function injectCustomKeyframes() {
|
||||
// pose dans une unique balise <style>, plutôt que d'en injecter une par
|
||||
// clip à chaque lecture — un nom de règle unique par clip (customAnim_id)
|
||||
// évite toute collision entre deux clips personnalisés différents.
|
||||
var rules = [];
|
||||
Object.keys(gameData.animations || {}).forEach(function(screenId) {
|
||||
(gameData.animations[screenId] || []).forEach(function(clip) {
|
||||
const rules = [];
|
||||
Object.keys(gameData.animations || {}).forEach((screenId) => {
|
||||
(gameData.animations[screenId] || []).forEach((clip) => {
|
||||
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
|
||||
var keyframes;
|
||||
let keyframes;
|
||||
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
|
||||
var steps = keyframes.map(function(k) {
|
||||
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
||||
return k.percent + '% { ' + props + ' }';
|
||||
const steps = keyframes.map((k) => {
|
||||
const props = Object.keys(k.props || {}).map((p) => `${p}:${k.props[p]};`).join(' ');
|
||||
return `${k.percent}% { ${props} }`;
|
||||
}).join(' ');
|
||||
rules.push('@keyframes customAnim_' + clip.id + ' { ' + steps + ' }');
|
||||
rules.push(`@keyframes customAnim_${clip.id} { ${steps} }`);
|
||||
});
|
||||
});
|
||||
var styleTag = document.getElementById('customAnimStyles');
|
||||
let styleTag = document.getElementById('customAnimStyles');
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = 'customAnimStyles';
|
||||
@@ -164,15 +165,16 @@ function applyAnimationClip(clip) {
|
||||
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
|
||||
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
|
||||
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
|
||||
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
|
||||
const targets = document.querySelectorAll(`[data-anim-target="${clip.element_id}"]`);
|
||||
if (!targets.length) return;
|
||||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||||
var iterationCount = clip.iteration_count || '1';
|
||||
var infinite = iterationCount === 'infinite';
|
||||
targets.forEach(function(target) {
|
||||
const duration = clip.duration || 1; const
|
||||
delay = clip.delay || 0;
|
||||
const iterationCount = clip.iteration_count || '1';
|
||||
const infinite = iterationCount === 'infinite';
|
||||
targets.forEach((target) => {
|
||||
if (clip.kind === 'custom') {
|
||||
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
|
||||
' ' + delay + 's ' + iterationCount;
|
||||
target.style.animation = `customAnim_${clip.id} ${duration}s ${clip.easing || 'ease'
|
||||
} ${delay}s ${iterationCount}`;
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
@@ -186,14 +188,14 @@ function applyAnimationClip(clip) {
|
||||
// ses frames — résolues ICI depuis le personnage ACTUELLEMENT
|
||||
// assigné, gameData.personnage_animations, pas figées au moment où
|
||||
// le clip a été configuré).
|
||||
var spriteData = {};
|
||||
let spriteData = {};
|
||||
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ }
|
||||
spriteData.loop = infinite;
|
||||
setTimeout(function() { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
||||
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
const animClass = `animate__${clip.animate_name}`;
|
||||
target.style.setProperty('--animate-duration', `${duration}s`);
|
||||
target.style.setProperty('--animate-delay', `${delay}s`);
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
if (!infinite) {
|
||||
@@ -215,17 +217,17 @@ function applyAnimationClip(clip) {
|
||||
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
|
||||
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
|
||||
function collectAnimationClips(screenId) {
|
||||
var visited = {};
|
||||
var clips = [];
|
||||
const visited = {};
|
||||
let clips = [];
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
|
||||
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach(function(el) {
|
||||
screenData.elements.forEach((el) => {
|
||||
if (!el.element_type_id) return;
|
||||
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
const templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
@@ -239,11 +241,11 @@ function runAnimationTimeline(screenId) {
|
||||
// attente d'un affichage précédent, pour ne jamais empiler des
|
||||
// déclenchements en double si le joueur revient plusieurs fois de
|
||||
// suite sur le même écran avant la fin de sa timeline.
|
||||
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
|
||||
pendingAnimationTimers.forEach((t) => { clearTimeout(t); });
|
||||
pendingAnimationTimers = [];
|
||||
var clips = collectAnimationClips(screenId);
|
||||
clips.forEach(function(clip) {
|
||||
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
const clips = collectAnimationClips(screenId);
|
||||
clips.forEach((clip) => {
|
||||
const t = setTimeout(() => { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
pendingAnimationTimers.push(t);
|
||||
});
|
||||
}
|
||||
|
||||
+38
-37
@@ -12,8 +12,8 @@ function runScreenShowTriggers(screenId) {
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'affichage'; })
|
||||
.forEach(function(n){ runFlowFrom(n.id); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'affichage')
|
||||
.forEach((n) => { runFlowFrom(n.id); });
|
||||
}
|
||||
|
||||
// Minuteur récurrent (Phase 3) : contrairement aux autres déclencheurs
|
||||
@@ -24,17 +24,17 @@ function runScreenShowTriggers(screenId) {
|
||||
// précédent (même principe que runAnimationTimeline) — sans ça, revenir
|
||||
// plusieurs fois sur le même écran accumulerait des setInterval en
|
||||
// double, chacun exécutant le graphe en plus des autres.
|
||||
var activeTimerTriggerIntervals = [];
|
||||
let activeTimerTriggerIntervals = [];
|
||||
|
||||
function runScreenTimerTriggers(screenId) {
|
||||
activeTimerTriggerIntervals.forEach(function(id) { clearInterval(id); });
|
||||
activeTimerTriggerIntervals.forEach((id) => { clearInterval(id); });
|
||||
activeTimerTriggerIntervals = [];
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0; })
|
||||
.forEach(function(n){
|
||||
var intervalId = setInterval(function(){ runFlowFrom(n.id, screenId); }, n.trigger_interval_ms);
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0)
|
||||
.forEach((n) => {
|
||||
const intervalId = setInterval(() => { runFlowFrom(n.id, screenId); }, n.trigger_interval_ms);
|
||||
activeTimerTriggerIntervals.push(intervalId);
|
||||
});
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function runScreenTimerTriggers(screenId) {
|
||||
function findTriggerNodess(elementId, event) {
|
||||
const found = [];
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes.forEach(function(n) {
|
||||
gameData.flows[screenIdKey].nodes.forEach((n) => {
|
||||
if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
|
||||
found.push({ node: n, screenId: screenIdKey });
|
||||
}
|
||||
@@ -86,13 +86,13 @@ function findTriggerNodess(elementId, event) {
|
||||
// en page imbriquée avec du texte brut — cette fonctionnalité vise les
|
||||
// éléments de texte simples (nom, lien...), pas les conteneurs.
|
||||
function bindHoverTexts() {
|
||||
document.querySelectorAll('[data-hover-text]').forEach(function(el) {
|
||||
document.querySelectorAll('[data-hover-text]').forEach((el) => {
|
||||
if (el.dataset.hoverBound) return;
|
||||
if (el.children.length > 0) return;
|
||||
el.dataset.hoverBound = '1';
|
||||
var original = el.textContent;
|
||||
el.addEventListener('mouseenter', function(){ el.textContent = el.dataset.hoverText; });
|
||||
el.addEventListener('mouseleave', function(){ el.textContent = original; });
|
||||
const original = el.textContent;
|
||||
el.addEventListener('mouseenter', () => { el.textContent = el.dataset.hoverText; });
|
||||
el.addEventListener('mouseleave', () => { el.textContent = original; });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ function bindHoverTexts() {
|
||||
// "bouton"/"lien", ne doit pas se voir écraser son rôle natif) ; Entrée/
|
||||
// Espace déclenchent le même comportement que le clic.
|
||||
function bindClicks() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const triggers = findTriggerNodess(elementId, 'clic');
|
||||
if (triggers.length) {
|
||||
@@ -137,7 +137,7 @@ function bindClicks() {
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? 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));
|
||||
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
};
|
||||
elDiv.addEventListener('click', activate);
|
||||
elDiv.addEventListener('keydown', (ev) => {
|
||||
@@ -163,7 +163,7 @@ function bindClicks() {
|
||||
// bindClicks() ci-dessus) doit aussi devenir focusable pour rester
|
||||
// atteignable au clavier.
|
||||
function bindHoverTriggers() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
if (elDiv.dataset.hoverTriggerBound) return;
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||
@@ -175,12 +175,12 @@ function bindHoverTriggers() {
|
||||
// le commentaire de findTriggerNodess (plusieurs déclencheurs
|
||||
// indépendants sur le même élément/événement doivent tous s'exécuter).
|
||||
if (enterTriggers.length) {
|
||||
const onEnter = () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
|
||||
const onEnter = () => enterTriggers.forEach((t) => runFlowFrom(t.node.id, t.screenId));
|
||||
elDiv.addEventListener('mouseenter', onEnter);
|
||||
elDiv.addEventListener('focus', onEnter);
|
||||
}
|
||||
if (leaveTriggers.length) {
|
||||
const onLeave = () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
|
||||
const onLeave = () => leaveTriggers.forEach((t) => runFlowFrom(t.node.id, t.screenId));
|
||||
elDiv.addEventListener('mouseleave', onLeave);
|
||||
elDiv.addEventListener('blur', onLeave);
|
||||
}
|
||||
@@ -204,22 +204,20 @@ function bindHoverTriggers() {
|
||||
// quel un champ de saisie ailleurs sur la page, par ex.).
|
||||
function isGameKey(key) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
if (gameData.flows[screenIdKey].nodes.some(function(n) {
|
||||
return n.node_type === 'trigger' &&
|
||||
(n.trigger_event === 'clavier' || n.trigger_event === 'touche_maintenue' || n.trigger_event === 'touche_relachee') &&
|
||||
n.trigger_key === key;
|
||||
})) return true;
|
||||
if (gameData.flows[screenIdKey].nodes.some((n) => n.node_type === 'trigger'
|
||||
&& (n.trigger_event === 'clavier' || n.trigger_event === 'touche_maintenue' || n.trigger_event === 'touche_relachee')
|
||||
&& n.trigger_key === key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bindKeyboardTriggers() {
|
||||
window.addEventListener('keydown', function (ev) {
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (isGameKey(ev.key)) ev.preventDefault();
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key; })
|
||||
.forEach(function(n){ runFlowFrom(n.id, screenIdKey); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key)
|
||||
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -235,24 +233,24 @@ function bindKeyboardTriggers() {
|
||||
// runScreenTimerTriggers). "touche_relachee" (keyup), lui, n'a pas besoin
|
||||
// d'être scopé par écran — un seul déclenchement ponctuel, même patron de
|
||||
// scan global que "clavier".
|
||||
var heldKeys = new Set();
|
||||
const heldKeys = new Set();
|
||||
|
||||
function bindHeldKeyTriggers() {
|
||||
window.addEventListener('keydown', function (ev) {
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (isGameKey(ev.key)) ev.preventDefault();
|
||||
heldKeys.add(ev.key);
|
||||
});
|
||||
window.addEventListener('keyup', function (ev) {
|
||||
window.addEventListener('keyup', (ev) => {
|
||||
heldKeys.delete(ev.key);
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'touche_relachee' && n.trigger_key === ev.key; })
|
||||
.forEach(function(n){ runFlowFrom(n.id, screenIdKey); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'touche_relachee' && n.trigger_key === ev.key)
|
||||
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
});
|
||||
// La fenêtre peut perdre le focus touche encore enfoncée (alt-tab...) —
|
||||
// sans ça, heldKeys garderait une touche "fantôme" indéfiniment maintenue.
|
||||
window.addEventListener('blur', function () { heldKeys.clear(); });
|
||||
window.addEventListener('blur', () => { heldKeys.clear(); });
|
||||
}
|
||||
|
||||
// Même patron PAR ÉCRAN que runScreenTimerTriggers() ci-dessus : n'existe
|
||||
@@ -260,17 +258,20 @@ function bindHeldKeyTriggers() {
|
||||
// à chaque changement d'écran (voir showScreen(), static/js/play/screens.js).
|
||||
// 50ms (20 fois/seconde) : assez fluide pour un déplacement perçu comme
|
||||
// continu, sans re-exécuter le graphe à une fréquence excessive.
|
||||
var activeHeldKeyTriggerInterval = null;
|
||||
let activeHeldKeyTriggerInterval = null;
|
||||
|
||||
function runScreenHeldKeyTriggers(screenId) {
|
||||
if (activeHeldKeyTriggerInterval) { clearInterval(activeHeldKeyTriggerInterval); activeHeldKeyTriggerInterval = null; }
|
||||
if (activeHeldKeyTriggerInterval) {
|
||||
clearInterval(activeHeldKeyTriggerInterval);
|
||||
activeHeldKeyTriggerInterval = null;
|
||||
}
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
const nodes = flow.nodes.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'touche_maintenue'; });
|
||||
const nodes = flow.nodes.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'touche_maintenue');
|
||||
if (!nodes.length) return;
|
||||
activeHeldKeyTriggerInterval = setInterval(function () {
|
||||
nodes.filter(function(n){ return heldKeys.has(n.trigger_key); })
|
||||
.forEach(function(n){ runFlowFrom(n.id, screenId); });
|
||||
activeHeldKeyTriggerInterval = setInterval(() => {
|
||||
nodes.filter((n) => heldKeys.has(n.trigger_key))
|
||||
.forEach((n) => { runFlowFrom(n.id, screenId); });
|
||||
}, 50);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
// overlay posé sur <body>, réutilisé pour chaque lecture (pas la peine
|
||||
// d'en recréer un par appel).
|
||||
|
||||
var forgeVideoOverlayEl = null;
|
||||
let forgeVideoOverlayEl = null;
|
||||
|
||||
function forgeVideoOverlayRoot() {
|
||||
if (forgeVideoOverlayEl) return forgeVideoOverlayEl;
|
||||
var el = document.createElement('div');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'forgeVideoOverlay';
|
||||
el.innerHTML =
|
||||
'<video class="forgeVideoOverlayPlayer" playsinline></video>' +
|
||||
'<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
el.innerHTML = '<video class="forgeVideoOverlayPlayer" playsinline></video>'
|
||||
+ '<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
document.body.appendChild(el);
|
||||
forgeVideoOverlayEl = el;
|
||||
return el;
|
||||
@@ -27,12 +26,12 @@ function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
if (onDone) onDone();
|
||||
return;
|
||||
}
|
||||
var root = forgeVideoOverlayRoot();
|
||||
root.className = 'forgeVideoOverlay ' + (mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran');
|
||||
const root = forgeVideoOverlayRoot();
|
||||
root.className = `forgeVideoOverlay ${mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran'}`;
|
||||
root.style.display = 'flex';
|
||||
var player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
var skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
var finished = false;
|
||||
const player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
const skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
let finished = false;
|
||||
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
@@ -48,7 +47,9 @@ function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
player.src = url;
|
||||
player.onended = finish;
|
||||
skipBtn.onclick = finish;
|
||||
player.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : reste affichée, "Passer" fonctionne toujours.
|
||||
// lecture bloquée par le navigateur (pas d'interaction récente) : reste
|
||||
// affichée, "Passer" fonctionne toujours.
|
||||
player.play().catch(() => {});
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
|
||||
Reference in New Issue
Block a user