Files
Forge-Engine/static/game/js/play/actions.js
T
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

568 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Exécution des actions ----------
// Extrait de templates/game/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: '%' },
};
// 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') {
applyContenuProperty(targetEl, value);
return;
}
if (property === 'surbrillance') {
applySurbrillanceProperty(targetEl, value);
return;
}
if (property === 'desactive') {
applyDesactiveProperty(targetEl, value);
return;
}
if (property === 'visibilite') {
applyVisibiliteProperty(targetEl, value);
return;
}
if (property === 'orientation') {
applyOrientationProperty(targetEl, value);
return;
}
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
applyRelativePositionProperty(targetEl, property, value);
return;
}
const spec = ELEMENT_PROPERTY_CSS[property];
if (!spec || value === undefined || value === null) return;
if (typeof value === 'string' && value.includes('||')) {
applyToggleValueProperty(targetEl, property, spec, value.split('||'));
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?.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;
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 = Number.parseFloat(value);
if (Number.isNaN(delta)) return;
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, Number.parseFloat(value) || 0)}px`;
return;
}
applyElementProperty(targetEl, property, value);
}
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 game_engine/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/game/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/, game_engine/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/game/test_sound.py, tests/game/test_sprite_animation.py,
// tests/game/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
// 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?.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/game/js/play/__tests__/ (node:test) — même patron que conditions.js.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
resolveSpriteFrames,
runSpriteAnimation,
stopAllSpriteAnimations,
activeSpriteAnimations,
applyElementProperty,
applyObjectProperty,
clampSceneObjectPosition,
runActionNode,
};
}