Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
66d8eaeae8
commit
ccf836f2c5
+363
-264
@@ -20,104 +20,130 @@ const ELEMENT_PROPERTY_CSS = {
|
||||
pos_y: { css: 'top', suffix: '%' },
|
||||
};
|
||||
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme pour
|
||||
// les autres réglages ci-dessous, cible n'importe quel élément — pointer
|
||||
// ceci vers un conteneur remplacerait tout son contenu imbriqué, à éviter.
|
||||
function applyContenuProperty(targetEl, value) {
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
}
|
||||
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans poser
|
||||
// de bordure colorée togglée à la main — utile pour un mentor qui attire
|
||||
// l'attention du joueur vers l'élément à toucher ensuite.
|
||||
function applySurbrillanceProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT de
|
||||
// "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au clic"
|
||||
// éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
function applyDesactiveProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
}
|
||||
|
||||
function applyVisibiliteProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue de
|
||||
// dos" pour chaque sens de déplacement.
|
||||
function applyOrientationProperty(targetEl, value) {
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||
// toujours au même endroit.
|
||||
function applyRelativePositionProperty(targetEl, property, value) {
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = Number.parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = Number.parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
}
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
function applyToggleValueProperty(targetEl, property, spec, parts) {
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
}
|
||||
|
||||
function applyElementProperty(targetEl, property, value) {
|
||||
if (property === 'contenu') {
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||||
// imbriqué, à éviter.
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
applyContenuProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'surbrillance') {
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
||||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
||||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
applySurbrillanceProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'desactive') {
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
||||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
||||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
applyDesactiveProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'visibilite') {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
applyVisibiliteProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'orientation') {
|
||||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
|
||||
// de dos" pour chaque sens de déplacement.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeFlipped');
|
||||
} else if (value === 'gauche') {
|
||||
targetEl.classList.add('forgeFlipped');
|
||||
} else {
|
||||
targetEl.classList.remove('forgeFlipped');
|
||||
}
|
||||
applyOrientationProperty(targetEl, value);
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||
// toujours au même endroit.
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
applyRelativePositionProperty(targetEl, property, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||
if (!spec || value === undefined || value === null) return;
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
if (typeof value === 'string' && value.includes('||')) {
|
||||
applyToggleValueProperty(targetEl, property, spec, value.split('||'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,8 +164,8 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// risquer un blocage à 0.
|
||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
if (screenData?.scene_width == null) return rawValue;
|
||||
const size = Number.parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||||
@@ -155,206 +181,278 @@ function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
function applyObjectProperty(targetEl, property, value) {
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||
const delta = parseFloat(value);
|
||||
const delta = Number.parseFloat(value);
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[css]) || 0;
|
||||
const current = Number.parseFloat(targetEl.style[css]) || 0;
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x' || property === 'pos_y') {
|
||||
if (value === undefined || value === null) return;
|
||||
const css = property === 'pos_x' ? 'left' : 'top';
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0)}px`;
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, Number.parseFloat(value) || 0)}px`;
|
||||
return;
|
||||
}
|
||||
applyElementProperty(targetEl, property, value);
|
||||
}
|
||||
|
||||
function runActionNode(node) {
|
||||
if (node.action_type === 'ecran_suivant') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||||
// précise.
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir
|
||||
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
||||
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
||||
// render_scene_object.py), donc le même sélecteur suffit.
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||
// signaler l'événement, jamais choisir un élément — c'est à
|
||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
||||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'attendre') {
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du
|
||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
const seconds = parseFloat(node.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
} if (node.action_type === 'modifier_donnee') {
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
|
||||
// aucun serveur disponible dans le paquet exporté — la même mutation
|
||||
// s'applique directement sur gameData, en mémoire, dans le navigateur.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyDataActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
||||
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
||||
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
|
||||
// sinon, donc toujours transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'modifier_variable') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'modifier_score') {
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyScoreActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'definir_statut_partie') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} if (node.action_type === 'ajouter_ligne') {
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
|
||||
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
|
||||
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
|
||||
// "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
} if (node.action_type === 'jouer_son') {
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
|
||||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
||||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||
return Promise.resolve();
|
||||
} if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try {
|
||||
spriteData = JSON.parse(node.data_value || '{}');
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||
// reste silencieusement absente (spriteData reste {}).
|
||||
console.warn('configuration sprite invalide', e);
|
||||
}
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 'rien' : aucune action volontairement.
|
||||
function runEcranSuivantAction() {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runEcranPrecedentAction() {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runAllerAAction(node) {
|
||||
if (!node.target_screen_id) return Promise.resolve();
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette action
|
||||
// (voir bindClicks dans triggers.js) puis affiche l'écran de détail avec
|
||||
// ses placeholders "champ" résolus contre cette ligne précise.
|
||||
function runOuvrirLigneAction(node) {
|
||||
if (!node.target_screen_id) return Promise.resolve();
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runModifierElementAction(node) {
|
||||
if (!node.target_element_id) return Promise.resolve();
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir applyObjectProperty()
|
||||
// plus bas) plutôt qu'en %. data-element-id est posé sur CHAQUE objet de
|
||||
// scène en plus de data-object-id (voir render_scene_object.py), donc le
|
||||
// même sélecteur suffit.
|
||||
function runModifierObjetSceneAction(node) {
|
||||
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que signaler
|
||||
// l'événement, jamais choisir un élément — c'est à l'écouteur (déclencheur
|
||||
// → condition → action) de décider quoi faire, avec ses propres réglages
|
||||
// habituels.
|
||||
function runDeclencherEvenementAction(node) {
|
||||
if (!node.target_custom_event_id) return Promise.resolve();
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id, y
|
||||
// compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par frère.
|
||||
// screenData.elements contient TOUS les éléments de l'écran (imbriqués
|
||||
// compris, voir list_elements côté serveur), donc ça fonctionne aussi
|
||||
// bien pour des onglets imbriqués dans un même conteneur que pour des
|
||||
// "vues" posées directement sur l'écran.
|
||||
function runActiverOngletAction(node) {
|
||||
if (!node.target_element_id) return Promise.resolve();
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du joueur
|
||||
// (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
function runAttendreAction(node) {
|
||||
const seconds = Number.parseFloat(node.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) : aucun
|
||||
// serveur disponible dans le paquet exporté — la même mutation s'applique
|
||||
// directement sur gameData, en mémoire, dans le navigateur.
|
||||
function runModifierDonneeAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyDataActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur SEULEMENT
|
||||
// si ce nœud vise respectivement CLICKED_ROW_ID ou LAST_INSERTED_ROW_ID
|
||||
// (voir flow_node_run_data.py) — sans effet sinon, donc toujours
|
||||
// transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
function runModifierVariableAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
// directement en mémoire dans le paquet exporté (voir apply-actions.js).
|
||||
function runModifierScoreAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyScoreActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
function runDefinirStatutPartieAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire hors
|
||||
// ligne) ; son id devient window.lastInsertedRowId pour que les nœuds
|
||||
// "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕
|
||||
// Dernière ligne ajoutée") sachent quelle ligne renseigner.
|
||||
// refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié.
|
||||
function runAjouterLigneAction(node) {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la musique
|
||||
// de fond de l'écran (runScreenBackgroundMusic() dans screens.js, qui
|
||||
// elle boucle et s'arrête au changement d'écran). Fire-and-forget : ne
|
||||
// bloque jamais la suite du graphe.
|
||||
function runJouerSonAction(node) {
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function runJouerAnimationSpriteAction(node) {
|
||||
if (!node.target_element_id && !node.target_object_id) return Promise.resolve();
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try {
|
||||
spriteData = JSON.parse(node.data_value || '{}');
|
||||
} catch (e) {
|
||||
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
|
||||
// data_value est produit par l'éditeur, jamais tapé à la main — un
|
||||
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
|
||||
// une action ponctuelle. Comportement joueur inchangé : l'animation
|
||||
// reste silencieusement absente (spriteData reste {}).
|
||||
console.warn('configuration sprite invalide', e);
|
||||
}
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Dispatch par action_type — chaque fonction ci-dessus vérifie ELLE-MÊME
|
||||
// ses conditions supplémentaires (ex. target_screen_id présent) et
|
||||
// renvoie une Promise résolue sans effet si elles ne sont pas remplies ;
|
||||
// ce dispatcher n'a donc plus qu'une seule décision (la présence d'un
|
||||
// handler), au lieu d'empiler ici les conditions de chaque branche
|
||||
// (c'était la source du score S3776 restant à 23 après la 1re extraction
|
||||
// en sous-fonctions — un enchaînement de if, même plat, coûte un point
|
||||
// par condition ET par opérateur booléen combiné).
|
||||
// Clés QUOTÉES délibérément (pas juste un style) : plusieurs tests Python
|
||||
// vérifient la présence du littéral quoté ("'jouer_son'" in js, etc.,
|
||||
// voir tests/test_sound.py, tests/test_sprite_animation.py,
|
||||
// tests/test_flow.py) comme preuve que ce action_type est bien implémenté
|
||||
// côté runtime — une clé nue (jouer_son: ...) fait disparaître ce
|
||||
// littéral du bundle JS sans rien changer au comportement, mais casse ces
|
||||
// tests. Gardé quoté pour rester détectable de la même façon.
|
||||
const ACTION_NODE_HANDLERS = {
|
||||
ecran_suivant: runEcranSuivantAction,
|
||||
ecran_precedent: runEcranPrecedentAction,
|
||||
aller_a: runAllerAAction,
|
||||
'ouvrir_ligne': runOuvrirLigneAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||
modifier_element: runModifierElementAction,
|
||||
modifier_objet_scene: runModifierObjetSceneAction,
|
||||
declencher_evenement: runDeclencherEvenementAction,
|
||||
activer_onglet: runActiverOngletAction,
|
||||
attendre: runAttendreAction,
|
||||
modifier_donnee: runModifierDonneeAction,
|
||||
modifier_variable: runModifierVariableAction,
|
||||
modifier_score: runModifierScoreAction,
|
||||
definir_statut_partie: runDefinirStatutPartieAction,
|
||||
ajouter_ligne: runAjouterLigneAction,
|
||||
'jouer_son': runJouerSonAction, // eslint-disable-line quote-props -- littéral quoté requis, voir commentaire au-dessus de ce dispatch
|
||||
'jouer_animation_sprite': runJouerAnimationSpriteAction, // eslint-disable-line quote-props -- idem
|
||||
};
|
||||
|
||||
function runActionNode(node) {
|
||||
const handler = ACTION_NODE_HANDLERS[node.action_type];
|
||||
// 'rien' (ou tout action_type inconnu) : aucune action volontairement.
|
||||
return handler ? handler(node) : Promise.resolve();
|
||||
}
|
||||
|
||||
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
||||
@@ -382,7 +480,7 @@ const activeSpriteAnimations = new Map();
|
||||
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||||
// les nœuds/clips déjà créés avant ce changement.
|
||||
function resolveSpriteFrames(elementId, spriteData) {
|
||||
const byAnimation = (gameData.personnage_animations || {})[elementId];
|
||||
const byAnimation = gameData.personnage_animations?.[elementId];
|
||||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||||
}
|
||||
@@ -418,7 +516,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
const loop = !!spriteData.loop;
|
||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||
const active = activeSpriteAnimations.get(targetEl);
|
||||
if (active && active.signature === signature) return;
|
||||
if (active?.signature === signature) return;
|
||||
if (active && !active.loop) return;
|
||||
if (active) {
|
||||
clearInterval(active.intervalId);
|
||||
@@ -461,6 +559,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
runSpriteAnimation,
|
||||
stopAllSpriteAnimations,
|
||||
activeSpriteAnimations,
|
||||
applyElementProperty,
|
||||
applyObjectProperty,
|
||||
clampSceneObjectPosition,
|
||||
runActionNode,
|
||||
|
||||
Reference in New Issue
Block a user