diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 9f4cc791..8be22e47 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -5,6 +5,7 @@ from ..elements.list_elements import list_elements from ..flow.list_flow_nodes import list_flow_nodes from ..flow.list_flow_edges import list_flow_edges from ..animations.list_animation_clips import list_animation_clips +from ..element_types.list_element_types import list_element_types 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"]] - 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, + } diff --git a/templates/play.html b/templates/play.html index e1db84c6..b49828f0 100644 --- a/templates/play.html +++ b/templates/play.html @@ -163,33 +163,69 @@ } function applyAnimationClip(clip) { - var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]'); - if (!target) return; + // querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un + // é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 iterationCount = clip.iteration_count || '1'; var infinite = iterationCount === 'infinite'; - if (clip.kind === 'custom') { - target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') + - ' ' + delay + 's ' + iterationCount; - if (!infinite) { - target.addEventListener('animationend', function handler() { - target.style.animation = ''; - target.removeEventListener('animationend', handler); - }); - } - } else { - var animClass = 'animate__' + clip.animate_name; - target.style.setProperty('--animate-duration', duration + 's'); - target.style.setProperty('--animate-delay', delay + 's'); - target.style.animationIterationCount = iterationCount; - target.classList.add('animate__animated', animClass); - if (!infinite) { - target.addEventListener('animationend', function handler() { - target.classList.remove('animate__animated', animClass); - target.removeEventListener('animationend', handler); - }); + targets.forEach(function(target) { + if (clip.kind === 'custom') { + target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') + + ' ' + delay + 's ' + iterationCount; + if (!infinite) { + target.addEventListener('animationend', function handler() { + target.style.animation = ''; + target.removeEventListener('animationend', handler); + }); + } + } else { + var animClass = 'animate__' + clip.animate_name; + target.style.setProperty('--animate-duration', duration + 's'); + target.style.setProperty('--animate-delay', delay + 's'); + target.style.animationIterationCount = iterationCount; + target.classList.add('animate__animated', animClass); + if (!infinite) { + target.addEventListener('animationend', function 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) { @@ -200,7 +236,7 @@ // suite sur le même écran avant la fin de sa timeline. pendingAnimationTimers.forEach(function(t) { clearTimeout(t); }); pendingAnimationTimers = []; - var clips = (gameData.animations || {})[String(screenId)] || []; + var clips = collectAnimationClips(screenId); clips.forEach(function(clip) { var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000); pendingAnimationTimers.push(t); @@ -233,11 +269,20 @@ // 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 // 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) { for (const screenIdKey in gameData.flows) { 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); - if (found) return found; + if (found) return { node: found, screenId: screenIdKey }; } return null; } @@ -290,7 +335,7 @@ const rowEl = ev.target.closest('.repeaterItem'); window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 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'); if (!enterTrigger && !leaveTrigger) return; elDiv.dataset.hoverTriggerBound = '1'; - if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.id)); - if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id)); + if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId)); + 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 // 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. - function runFlowFrom(nodeId) { - const flow = gameData.flows[String(window.currentScreenId)]; + // flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut + // 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; const nodesById = {}; flow.nodes.forEach(n => { nodesById[n.id] = n; });