- Lot 1 (S8786, ReDoS) : 5 sites documentes NOSONAR apres preuve empirique (script reproductible docs/redos_probe_s8786.js), aucune reecriture defensive necessaire. - Lot 2 (S2703, variable globale implicite) : bug reel trouve et corrige (SCENE_OBJECT_NAMES en const au lieu de let, cassait la reassignation cross-script depuis scene-editor.js) + test de non-regression ; 4 autres sites confirmes surs et documentes. - Lot 3 (S2486, exceptions avalees) : 6 sites confirmes surs et documentes ; 2 sites (config sprite JSON invalide) corriges avec un console.warn devtools, comportement joueur inchange, couverts par un nouveau test. - Retrait du job CI sonarqube (.gitea/workflows/deploy.yml) et du service prod sonarqube/sonar-postgres (docker-compose.prod.yml) : acces dashboard bloque par des soucis d'infrastructure reseau (WSL2/pare-feu Hyper-V en local, reseau Docker partage avec Caddy pas en place en prod), sans lien avec le code du moteur - mis de cote plutot que de continuer a bloquer sur de l'infra. Les lots 4+ de modernisation JS dependent de scores Sonar exacts et sont donc egalement en pause (voir CODE_QUALITY.md). SKIP=djlint : H021 (styles inline, 49 occurrences) est un backlog deja documente et assume (CODE_QUALITY.md section 6), sur des templates non touches par ce commit - deja exclu de la CI pour la meme raison. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
469 lines
24 KiB
JavaScript
469 lines
24 KiB
JavaScript
// ---------- Exécution des actions ----------
|
||
// Extrait de templates/play.html (voir plan de modularisation) :
|
||
// application d'une propriété d'élément ("Modifier un élément") et
|
||
// exécution de chaque type de nœud Action. Les URLs de routes serveur
|
||
// (flow_node_run_data/run_variable) sont injectées par play.html dans
|
||
// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici).
|
||
|
||
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
|
||
// pilotable par une action "Modifier un élément".
|
||
const ELEMENT_PROPERTY_CSS = {
|
||
bg_color: { css: 'backgroundColor', suffix: '' },
|
||
border_color: { css: 'borderColor', suffix: '' },
|
||
width: { css: 'width', suffix: '%' },
|
||
height: { css: 'height', suffix: '%' },
|
||
// Phase 5 (moteur) — les éléments sont DÉJÀ positionnés en absolu avec
|
||
// left/top en % (voir filters/element_style_filter.py) : piloter ces
|
||
// deux propriétés est donc cohérent avec l'existant, pas une nouvelle
|
||
// mécanique de positionnement.
|
||
pos_x: { css: 'left', suffix: '%' },
|
||
pos_y: { css: 'top', suffix: '%' },
|
||
};
|
||
|
||
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;
|
||
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');
|
||
}
|
||
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');
|
||
}
|
||
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 = '';
|
||
}
|
||
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');
|
||
}
|
||
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;
|
||
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';
|
||
return;
|
||
}
|
||
|
||
targetEl.style[spec.css] = value + spec.suffix;
|
||
}
|
||
|
||
// Bloque un objet de scène dans les limites du MONDE affiché (bug signalé
|
||
// par l'utilisateur : un déplacement continu au clavier — voir
|
||
// triggers.js/"touche_maintenue" — pouvait le faire sortir du cadre sans
|
||
// rien pour l'en empêcher). world_width/world_height (voir
|
||
// personnage-controller.js::forgeUpdateSceneWorldSize, appelée à chaque
|
||
// tick AVANT tout déplacement) valent scene_width/scene_height tant
|
||
// qu'aucun objet kind="fond" plus grand que la scène n'est posé — un jeu
|
||
// sans fond XXL borne donc exactement comme avant. scene_width/height
|
||
// viennent de screensData (voir full_game_payload.py,
|
||
// _screens.scene_width/height) ; sans écran trouvé (jamais censé arriver
|
||
// 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((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;
|
||
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||
return Math.max(0, Math.min(max, rawValue));
|
||
}
|
||
|
||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent
|
||
// d'applyElementProperty() ci-dessus pour un objet de scène (action
|
||
// "Modifier un objet de scène"), mais en PIXELS (pas de %, voir
|
||
// scene_width/scene_height) — visibilite/orientation restent identiques
|
||
// à l'éditeur document (déléguées à applyElementProperty), seule la
|
||
// position change de suffixe.
|
||
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 (Number.isNaN(delta)) return;
|
||
const current = 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`;
|
||
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.
|
||
return 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
|
||
// dans screens.js, qui sont par-écran/singleton) : plusieurs personnages
|
||
// peuvent animer indépendamment en même temps. Clé = le NŒUD DOM lui-même
|
||
// (pas element_id) : un clip posé sur un écran-modèle peut viser plusieurs
|
||
// instances à la fois (ex. chaque ligne d'un Répéteur utilisant ce modèle),
|
||
// toutes partageant le même element_id — les indexer par élément DOM réel
|
||
// évite qu'une nouvelle instance n'arrête l'animation d'une autre. Alimenté
|
||
// par deux entrées équivalentes — l'action de flow "jouer_animation_sprite"
|
||
// 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.
|
||
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
|
||
// (gameData.personnage_animations, reconstruit à chaque chargement de la
|
||
// page de jeu depuis _personnage_data — voir full_game_payload.py) plutôt
|
||
// que depuis des frames figées au moment où le créateur a configuré
|
||
// l'action/le clip : sans ça, changer le personnage Forge d'un élément
|
||
// dans l'éditeur n'avait aucun effet sur les animations déjà posées dans
|
||
// la logique de flow/la Timeline, qui continuaient à jouer indéfiniment
|
||
// les frames de l'ANCIEN personnage (bug signalé par l'utilisateur).
|
||
// `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];
|
||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||
}
|
||
return spriteData;
|
||
}
|
||
|
||
// Mouvement continu (touche maintenue, voir triggers.js) — un déclencheur
|
||
// "touche_maintenue" ré-exécute tout son graphe à CHAQUE tick pendant que
|
||
// la touche reste enfoncée, donc une action "Jouer une animation" posée
|
||
// dessus (ex. "marche") était rappelée des dizaines de fois par seconde :
|
||
// sans idempotence, chaque appel redémarrait l'animation à sa frame 0
|
||
// (clearInterval + reset), la figeant visuellement au lieu de la faire
|
||
// boucler normalement. Un appel qui demande la MÊME animation (mêmes
|
||
// frames/fps/boucle) qu'une animation déjà active sur cette cible ne fait
|
||
// donc plus rien — la laisse simplement continuer son cycle en cours.
|
||
//
|
||
// PRIORITÉ (bug signalé — "je ne peux pas me déplacer et sauter") : si le
|
||
// joueur maintient une touche de déplacement (donc "marche" redemandée en
|
||
// boucle, toutes les ~50ms) et appuie sur "sauter" PENDANT ce temps, le
|
||
// saut démarrait bien mais était écrasé par la toute prochaine demande de
|
||
// "marche" avant d'avoir eu le temps de s'afficher. Une animation NON
|
||
// BOUCLÉE actuellement active (typiquement une action ponctuelle — sauter,
|
||
// attaquer — par opposition à marche/idle qui bouclent en continu) va
|
||
// désormais AU BOUT avant qu'une autre demande, même différente, puisse
|
||
// l'interrompre. Vrai aussi pour une pose à UNE SEULE frame (ex. "jump"
|
||
// chez Kenney, une pose figée plutôt qu'un cycle) : sans minuterie propre
|
||
// à faire avancer des frames, elle serait resortie de activeSpriteAnimations
|
||
// 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) {
|
||
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) {
|
||
clearInterval(active.intervalId);
|
||
activeSpriteAnimations.delete(targetEl);
|
||
}
|
||
if (!frames.length) return;
|
||
let frameIndex = 0;
|
||
targetEl.src = frames[0];
|
||
if (frames.length === 1) {
|
||
if (loop) return;
|
||
const holdMs = Math.max(1000 / fps, 150);
|
||
const timeoutId = setTimeout(() => { activeSpriteAnimations.delete(targetEl); }, holdMs);
|
||
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature, loop });
|
||
return;
|
||
}
|
||
const intervalId = setInterval(() => {
|
||
frameIndex++;
|
||
if (frameIndex >= frames.length) {
|
||
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
|
||
frameIndex = 0;
|
||
}
|
||
targetEl.src = frames[frameIndex];
|
||
}, 1000 / fps);
|
||
activeSpriteAnimations.set(targetEl, { intervalId, signature, loop });
|
||
}
|
||
|
||
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
|
||
// encapsule activeSpriteAnimations ici plutôt que de le manipuler
|
||
// 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((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,
|
||
};
|
||
}
|