Fait fonctionner logique et animations d'un modèle réutilisable partout où il est posé

Jusqu'ici, la logique (déclencheurs Au clic/Au survol/Fin du survol) et
les animations posées dans l'éditeur de l'écran-MODÈLE d'un élément de
jeu réutilisable (ex. "mail content") sur SES PROPRES enfants ne
s'exécutaient jamais quand cet élément était simplement posé sur une
autre scène : findTriggerNode() cherchait bien le déclencheur dans tous
les écrans (modèles compris) mais runFlowFrom() n'exécutait ensuite le
graphe que dans l'écran RÉELLEMENT affiché — le nœud trouvé n'existait
pas dans ce graphe-là, donc rien ne se déclenchait, silencieusement.
Même limitation pour les animations, dont la timeline ne lisait que les
clips propres à l'écran affiché.

Logique (templates/play.html) :
- findTriggerNode() renvoie désormais { node, screenId } plutôt que
  juste le nœud, pour transmettre l'écran D'ORIGINE du déclencheur (qui
  peut être un écran-modèle).
- runFlowFrom(nodeId, flowScreenId) accepte un 2e paramètre optionnel
  (par défaut l'écran affiché, comportement inchangé pour tout le
  reste) pour exécuter le graphe dans le BON écran.
- bindClicks()/bindHoverTriggers() passent maintenant cet écran
  d'origine à runFlowFrom(). runScreenShowTriggers() (déclencheur "À
  l'affichage de l'écran") reste volontairement inchangé — hors scope,
  ambiguïté sur plusieurs exemplaires d'un même modèle sur un écran.

Animations (screens/payload/full_game_payload.py, templates/play.html) :
- Le payload expose désormais element_types (element_type_id -> id de
  son écran-modèle), via screens.list_element_types() déjà existant.
- collectAnimationClips(screenId) rassemble récursivement les clips de
  l'écran affiché ET de tout écran-modèle utilisé par un de ses
  éléments (garde anti-boucle, dédoublonnage par écran).
- applyAnimationClip() cible désormais TOUS les exemplaires d'un id
  d'élément (querySelectorAll, plus querySelector) : un enfant de
  modèle garde le même id à chaque exemplaire, y compris pour chaque
  ligne d'un Répéteur utilisant ce modèle comme gabarit de ligne.

Limite connue, non corrigée ici (pas la demande) : une action "Modifier
un élément" ciblant un enfant de modèle reste, elle, scopée au premier
exemplaire trouvé dans le DOM (document.querySelector singulier dans
runActionNode/applyElementProperty) — sans impact pour un modèle posé
une seule fois par écran, comme dans le cas d'usage actuel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-27 07:33:25 +02:00
co-authored by Claude Sonnet 5
parent 19d3810164
commit 17c5e93d8d
2 changed files with 95 additions and 30 deletions
+14 -1
View File
@@ -5,6 +5,7 @@ from ..elements.list_elements import list_elements
from ..flow.list_flow_nodes import list_flow_nodes from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
def full_game_payload(slug): def full_game_payload(slug):
@@ -42,4 +43,16 @@ def full_game_payload(slug):
] ]
fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]] fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]]
return {"screens": payload_screens, "flows": flows, "animations": animations, "data": data, "fields_meta": fields_meta} # element_type_id -> id de son écran-modèle : nécessaire côté client (voir
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
# aussi être joués à l'affichage de cet écran — sans quoi une animation
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
}
+81 -29
View File
@@ -163,33 +163,69 @@
} }
function applyAnimationClip(clip) { function applyAnimationClip(clip) {
var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]'); // querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un
if (!target) return; // écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres
// enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce
// modèle (rechargé en direct depuis le même écran-modèle, voir
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
if (!targets.length) return;
var duration = clip.duration || 1, delay = clip.delay || 0; var duration = clip.duration || 1, delay = clip.delay || 0;
var iterationCount = clip.iteration_count || '1'; var iterationCount = clip.iteration_count || '1';
var infinite = iterationCount === 'infinite'; var infinite = iterationCount === 'infinite';
if (clip.kind === 'custom') { targets.forEach(function(target) {
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') + if (clip.kind === 'custom') {
' ' + delay + 's ' + iterationCount; target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
if (!infinite) { ' ' + delay + 's ' + iterationCount;
target.addEventListener('animationend', function handler() { if (!infinite) {
target.style.animation = ''; target.addEventListener('animationend', function handler() {
target.removeEventListener('animationend', handler); target.style.animation = '';
}); target.removeEventListener('animationend', handler);
} });
} else { }
var animClass = 'animate__' + clip.animate_name; } else {
target.style.setProperty('--animate-duration', duration + 's'); var animClass = 'animate__' + clip.animate_name;
target.style.setProperty('--animate-delay', delay + 's'); target.style.setProperty('--animate-duration', duration + 's');
target.style.animationIterationCount = iterationCount; target.style.setProperty('--animate-delay', delay + 's');
target.classList.add('animate__animated', animClass); target.style.animationIterationCount = iterationCount;
if (!infinite) { target.classList.add('animate__animated', animClass);
target.addEventListener('animationend', function handler() { if (!infinite) {
target.classList.remove('animate__animated', animClass); target.addEventListener('animationend', function handler() {
target.removeEventListener('animationend', handler); target.classList.remove('animate__animated', animClass);
}); target.removeEventListener('animationend', handler);
});
}
} }
});
}
// Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE
// utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet
// écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser
// un autre modèle), avec une garde anti-boucle et un dédoublonnage par
// écran (un même modèle posé plusieurs fois ne doit pas répéter ses
// clips). Sans ça, une animation posée directement dans l'éditeur d'un
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
function collectAnimationClips(screenId) {
var visited = {};
var clips = [];
function visit(sid) {
if (visited[sid]) return;
visited[sid] = true;
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
if (!screenData) return;
screenData.elements.forEach(function(el) {
if (!el.element_type_id) return;
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
if (templateScreenId != null) visit(templateScreenId);
});
} }
visit(screenId);
return clips;
} }
function runAnimationTimeline(screenId) { function runAnimationTimeline(screenId) {
@@ -200,7 +236,7 @@
// suite sur le même écran avant la fin de sa timeline. // suite sur le même écran avant la fin de sa timeline.
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); }); pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
pendingAnimationTimers = []; pendingAnimationTimers = [];
var clips = (gameData.animations || {})[String(screenId)] || []; var clips = collectAnimationClips(screenId);
clips.forEach(function(clip) { clips.forEach(function(clip) {
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000); var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
pendingAnimationTimers.push(t); pendingAnimationTimers.push(t);
@@ -233,11 +269,20 @@
// fois, sur tous les éléments de tous les écrans (déjà tous présents dans // fois, sur tous les éléments de tous les écrans (déjà tous présents dans
// le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant // le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant
// d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible. // d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible.
// Cette recherche globale couvre aussi les écrans-MODÈLES (un élément de
// jeu réutilisable, ex. "mail content", posé sur une scène) : un
// déclencheur "Au clic"/"Au survol" posé dans l'éditeur du modèle, sur un
// de SES PROPRES enfants, est donc retrouvé même quand ce modèle est
// utilisé ailleurs — d'où le besoin de renvoyer aussi l'écran d'origine du
// nœud (screenIdKey), pas seulement le nœud : runFlowFrom() doit exécuter
// le graphe dans CET écran-là (celui du modèle), jamais dans celui
// réellement affiché, sans quoi le nœud trouvé n'existerait pas dans le
// graphe utilisé et l'action ne se déclencherait jamais.
function findTriggerNode(elementId, event) { function findTriggerNode(elementId, event) {
for (const screenIdKey in gameData.flows) { for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey]; const flow = gameData.flows[screenIdKey];
const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event); const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event);
if (found) return found; if (found) return { node: found, screenId: screenIdKey };
} }
return null; return null;
} }
@@ -290,7 +335,7 @@
const rowEl = ev.target.closest('.repeaterItem'); const rowEl = ev.target.closest('.repeaterItem');
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null; window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null; window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
runFlowFrom(trigger.id); runFlowFrom(trigger.node.id, trigger.screenId);
}); });
} }
}); });
@@ -311,8 +356,8 @@
const leaveTrigger = findTriggerNode(elementId, 'fin_survol'); const leaveTrigger = findTriggerNode(elementId, 'fin_survol');
if (!enterTrigger && !leaveTrigger) return; if (!enterTrigger && !leaveTrigger) return;
elDiv.dataset.hoverTriggerBound = '1'; elDiv.dataset.hoverTriggerBound = '1';
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.id)); if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId));
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id)); if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId));
}); });
} }
@@ -819,8 +864,15 @@
// seulement le premier. Chaque nœud Action produit son effet puis // seulement le premier. Chaque nœud Action produit son effet puis
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue // continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence. // une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
function runFlowFrom(nodeId) { // flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
const flow = gameData.flows[String(window.currentScreenId)]; // l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode)
// quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un
// élément de jeu réutilisable, sur un de ses propres enfants. Sans ce
// paramètre, un tel déclencheur était bien retrouvé (findTriggerNode
// cherche dans tous les écrans) mais son graphe n'existait jamais dans
// l'écran réellement affiché : rien ne se déclenchait, silencieusement.
function runFlowFrom(nodeId, flowScreenId) {
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
if (!flow) return; if (!flow) return;
const nodesById = {}; const nodesById = {};
flow.nodes.forEach(n => { nodesById[n.id] = n; }); flow.nodes.forEach(n => { nodesById[n.id] = n; });