From 70c2b8df057702d558d963048b115ed61dbbfb40 Mon Sep 17 00:00:00 2001 From: william Date: Mon, 31 Aug 2026 08:56:20 +0200 Subject: [PATCH] =?UTF-8?q?Corrige=20:=20une=20animation=20d=C3=A9j=C3=A0?= =?UTF-8?q?=20pos=C3=A9e=20dans=20la=20logique=20ignorait=20le=20changemen?= =?UTF-8?q?t=20de=20personnage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug réel identifié grâce à la vidéo fournie + inspection directe de la base de jeu de test : quand un nœud de flow "Jouer une animation" (ou un clip de Timeline "sprite") était configuré pour un personnage, ses frames étaient résolues et FIGÉES dans data_value/custom_keyframes au moment de la configuration — changer ensuite le personnage Forge de l'élément (galerie des propriétés) n'avait donc aucun effet sur les animations déjà posées, qui continuaient à jouer indéfiniment les frames de l'ANCIEN personnage. Le nœud/clip ne stocke désormais que le NOM de l'animation ({"animation": "walk", "fps": 8, "loop": true}) — ses frames sont résolues à l'EXÉCUTION, à partir du personnage ACTUELLEMENT assigné à l'élément cible : - screens/payload/full_game_payload.py expose un nouveau gameData.personnage_animations (élément → animations), reconstruit à chaque chargement de la page de jeu depuis _personnage_data — donc toujours à jour, y compris après un changement de personnage. - static/js/play/actions.js (resolveSpriteFrames) et static/js/play/screens.js (applyAnimationClip) résolvent le nom d'animation en frames à ce moment précis, plutôt que d'utiliser des frames figées — repli sur l'ancien format {frames,...} pour les nœuds/clips déjà créés avant ce correctif. - Éditeur (flow-editor.js/animation-timeline.js) : simplifié en conséquence — plus besoin de deviner rétroactivement quelle animation correspond à une liste de frames stockées (l'ancien hack de comparaison), le nom est maintenant stocké directement. Nouveau test de régression (test_swapping_forge_character_updates_ already_configured_flow_action) qui reproduit exactement le scénario filmé : configure l'action pour "male-adventurer", change le personnage en "zombie", vérifie que gameData.personnage_animations reflète bien zombie sans avoir à retoucher le nœud de flow. Co-Authored-By: Claude Sonnet 5 --- screens/payload/full_game_payload.py | 20 +++++++++- static/js/play/actions.js | 21 ++++++++++- static/js/play/screens.js | 12 +++--- static/js/screen_edit/animation-timeline.js | 42 ++++++++++----------- static/js/screen_edit/flow-editor.js | 19 +++++----- tests/test_sprite_animation.py | 37 ++++++++++++++++++ 6 files changed, 112 insertions(+), 39 deletions(-) diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 913b757d..f65e4be2 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -6,6 +6,7 @@ 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 +from ..rendering.personnage_data import resolve_personnage_animations def full_game_payload(slug, player_id=db.PLAYER_SHARED): @@ -45,12 +46,29 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # écran-modèle ne doit jamais être rendu comme un vrai
(il n'est jamais affiché tel quel, seulement # rechargé en direct à l'intérieur d'un élément qui l'utilise). + # Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE + # affichage de la page de jeu (donc toujours à partir du personnage + # Forge ACTUELLEMENT assigné), plutôt que figées dans le nœud de flow/ + # le clip de Timeline au moment où le créateur les a configurés : sans + # ça, changer le personnage Forge d'un élément dans l'éditeur laissait + # ses animations déjà posées dans la logique/la Timeline continuer à + # jouer les frames de l'ANCIEN personnage indéfiniment (bug signalé — + # voir runActionNode()/applyAnimationClip() qui font cette résolution + # à l'exécution à partir de gameData.personnage_animations plutôt que + # depuis un data_value/custom_keyframes qui contiendrait des frames + # déjà résolues). include_templates=True : un personnage peut vivre + # dans un écran-modèle d'élément de jeu réutilisable, comme les flows/ + # animations ci-dessous. + personnage_animations = {} for s in list_screens(slug, include_templates=True): flows[str(s["id"])] = { "nodes": list_flow_nodes(slug, s["id"]), "edges": list_flow_edges(slug, s["id"]), } animations[str(s["id"])] = list_animation_clips(slug, s["id"]) + for el in list_elements(slug, s["id"]): + if el.get("widget") == "personnage": + personnage_animations[str(el["id"])] = resolve_personnage_animations(el) definitions = db.list_definitions(slug) data = {} @@ -88,5 +106,5 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): return { "screens": payload_screens, "flows": flows, "animations": animations, "element_types": element_types, "data": data, "fields_meta": fields_meta, - "variables": variables, + "variables": variables, "personnage_animations": personnage_animations, } diff --git a/static/js/play/actions.js b/static/js/play/actions.js index f0b4ad7c..390aa0ed 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -245,7 +245,7 @@ function runActionNode(node) { if (targetEl) { let spriteData = {}; try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ } - runSpriteAnimation(targetEl, spriteData); + runSpriteAnimation(targetEl, resolveSpriteFrames(node.target_element_id, spriteData)); } return Promise.resolve(); } @@ -268,6 +268,25 @@ function runActionNode(node) { // deux partagent ce même moteur, aucune logique dupliquée. var 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) { + var 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; +} + function runSpriteAnimation(targetEl, spriteData) { var frames = spriteData.frames || []; var fps = spriteData.fps > 0 ? spriteData.fps : 8; diff --git a/static/js/play/screens.js b/static/js/play/screens.js index 1d8aa7e8..c340b7f7 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -147,14 +147,16 @@ function applyAnimationClip(clip) { }); } } else if (clip.kind === 'sprite') { - // Phase 7 — même moteur que l'action de flow "jouer_animation_sprite" - // (voir runSpriteAnimation() dans actions.js) : custom_keyframes - // stocke ici {frames, fps} en JSON plutôt que des images-clés CSS - // (réutilise la colonne générique existante, pas de nouveau champ). + // Phase 8 — même moteur que l'action de flow "jouer_animation_sprite" + // (voir runSpriteAnimation()/resolveSpriteFrames() dans actions.js) : + // custom_keyframes stocke {animation, fps} (nom de l'animation, pas + // ses frames — résolues ICI depuis le personnage ACTUELLEMENT + // assigné, gameData.personnage_animations, pas figées au moment où + // le clip a été configuré). var spriteData = {}; try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ } spriteData.loop = infinite; - setTimeout(function() { runSpriteAnimation(target, spriteData); }, delay * 1000); + setTimeout(function() { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000); } else { var animClass = 'animate__' + clip.animate_name; target.style.setProperty('--animate-duration', duration + 's'); diff --git a/static/js/screen_edit/animation-timeline.js b/static/js/screen_edit/animation-timeline.js index 2e482d6d..4d143e17 100644 --- a/static/js/screen_edit/animation-timeline.js +++ b/static/js/screen_edit/animation-timeline.js @@ -269,25 +269,18 @@ function openAnimClipForm(clip) { var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' '); addAnimKeyframeRow(k.percent, cssText); }); - // Phase 8 — clip.custom_keyframes stocke {frames, fps} en JSON pour un - // clip kind="sprite" (pas une liste d'images-clés CSS, voir ci-dessus) ; - // le NOM de l'animation, lui, n'est pas stocké (seules ses frames le - // sont) — retrouvé ici par comparaison avec les animations actuelles du - // personnage propriétaire, pour présélectionner la bonne miniature. + // Phase 8 — clip.custom_keyframes stocke {animation, fps} en JSON pour un + // clip kind="sprite" (pas une liste d'images-clés CSS, voir ci-dessus) : + // le NOM de l'animation, pas ses frames — résolues à l'exécution depuis + // le personnage ACTUELLEMENT assigné (voir resolveSpriteFrames() dans + // static/js/play/actions.js), jamais figées ici. var spriteData = {}; if (clip.kind === 'sprite' && clip.custom_keyframes) { try { spriteData = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ } } document.getElementById('anim-clip-sprite-fps').value = spriteData.fps || 8; toggleAnimClipKind(); - if (clip.kind === 'sprite') { - var ownerAnimations = ELEMENT_ANIMATIONS_MAP[clip.element_id] || {}; - var matchedName = Object.keys(ownerAnimations).find(function(name) { - var f = ownerAnimations[name]; - return spriteData.frames && f.length === spriteData.frames.length && f.every(function(u, i) { return u === spriteData.frames[i]; }); - }) || null; - populateAnimClipSpriteGrid(matchedName); - } + if (clip.kind === 'sprite') populateAnimClipSpriteGrid(spriteData.animation); document.querySelectorAll('.animClip').forEach(function(b) { b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id); }); @@ -313,16 +306,15 @@ function saveAnimClipForm() { }; if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes(); if (kind === 'sprite') { - // Phase 8 — l'animation choisie (miniature cliquée, voir - // populateAnimClipSpriteGrid()) est résolue en liste de frames ICI ; - // {frames, fps} stocké dans custom_keyframes reste inchangé depuis la - // Phase 7 (moteur d'exécution intact, seule l'UI a changé) — la boucle - // reste portée par iteration_count (1/2/3/infinite, ci-dessus). - var clipForFields = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; }); - var ownerAnimations = clipForFields ? (ELEMENT_ANIMATIONS_MAP[clipForFields.element_id] || {}) : {}; - var animName = document.getElementById('anim-clip-sprite-animation-name').value; + // Phase 8 — stocke le NOM de l'animation choisie (miniature cliquée, + // voir populateAnimClipSpriteGrid()), PAS ses frames — résolues à + // l'exécution depuis le personnage ACTUELLEMENT assigné (voir + // resolveSpriteFrames() dans static/js/play/actions.js), pour que + // changer de personnage Forge affecte aussi les clips déjà posés dans + // la Timeline. La boucle reste portée par iteration_count + // (1/2/3/infinite, ci-dessus). fields.custom_keyframes = { - frames: ownerAnimations[animName] || [], + animation: document.getElementById('anim-clip-sprite-animation-name').value, fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8, }; } @@ -371,7 +363,11 @@ function previewAnimClip() { } else if (clip.kind === 'sprite') { var spriteData = {}; try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* ignore */ } - var frames = spriteData.frames || []; + // Phase 8 — résout le NOM de l'animation depuis les animations + // ACTUELLES du personnage (même logique que resolveSpriteFrames() + // côté jeu, mais ELEMENT_ANIMATIONS_MAP n'existe que dans l'éditeur). + var ownerAnimations = ELEMENT_ANIMATIONS_MAP[clip.element_id] || {}; + var frames = ownerAnimations[spriteData.animation] || []; var fps = spriteData.fps > 0 ? spriteData.fps : 8; if (animPreviewSpriteInterval) { clearInterval(animPreviewSpriteInterval); animPreviewSpriteInterval = null; } if (frames.length) { diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index f581ba35..ba12a951 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -121,7 +121,7 @@ function nodeLabel(node) { base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?'); try { var spriteData = JSON.parse(node.data_value || '{}'); - base += ' (' + (spriteData.frames ? spriteData.frames.length : 0) + ' images, ' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')'; + base += ' « ' + (spriteData.animation || '?') + ' » (' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')'; } catch (e) { /* data_value invalide : pas de détail affiché */ } } return base; @@ -745,17 +745,18 @@ function submitNodeForm() { // colonne dédiée. payload.data_value = document.getElementById('nf-action-sound-url').value; } else if (actionType === 'jouer_animation_sprite') { - // Phase 8 — l'animation choisie (miniature cliquée, voir - // onSpriteTargetElementChange()) est résolue en liste de frames ICI, - // à l'enregistrement : le nœud stocké reste exactement {frames, fps, - // loop} (Phase 7, moteur d'exécution inchangé — voir runSpriteAnimation() - // dans static/js/play/actions.js), seule l'UI d'édition a changé. + // Phase 8 — stocke le NOM de l'animation choisie (miniature cliquée, + // voir onSpriteTargetElementChange()), PAS ses frames : résolues à + // l'exécution depuis le personnage ACTUELLEMENT assigné à la cible + // (voir resolveSpriteFrames() dans static/js/play/actions.js) — + // sinon changer le personnage Forge de l'élément dans l'éditeur + // n'avait aucun effet sur les animations déjà posées dans la + // logique, qui continuaient à jouer les frames de l'ancien + // personnage indéfiniment (bug signalé par l'utilisateur). var elementId = document.getElementById('nf-action-sprite-target-element').value; - var animName = document.getElementById('nf-action-sprite-animation-name').value; - var animations = elementId ? (ELEMENT_ANIMATIONS_MAP[elementId] || {}) : {}; payload.target_element_id = parseInt(elementId, 10); payload.data_value = JSON.stringify({ - frames: animations[animName] || [], + animation: document.getElementById('nf-action-sprite-animation-name').value, fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8, loop: document.getElementById('nf-action-sprite-loop').checked, }); diff --git a/tests/test_sprite_animation.py b/tests/test_sprite_animation.py index 758d9f87..618d8564 100644 --- a/tests/test_sprite_animation.py +++ b/tests/test_sprite_animation.py @@ -101,6 +101,43 @@ def test_play_page_exposes_sprite_animation_runtime(client, game): assert "activeSpriteAnimations" in js assert "stopAllSpriteAnimations" in js assert "forgeFlipped" in js + # Phase 8 — résolution à l'exécution depuis le personnage actuellement + # assigné (voir résolution du bug ci-dessous), pas des frames figées. + assert "resolveSpriteFrames" in js + assert "personnage_animations" in js + + +def test_swapping_forge_character_updates_already_configured_flow_action(client, game): + """Régression : un nœud "Jouer une animation" configuré pour un + personnage devait rester bloqué sur les frames de l'ANCIEN personnage + même après avoir changé le personnage Forge de l'élément dans + l'éditeur (signalé par l'utilisateur). data_value ne stocke plus que + le NOM de l'animation — resolveSpriteFrames() la résout à l'exécution + depuis gameData.personnage_animations, reconstruit à chaque + chargement de full_game_payload() à partir du personnage ACTUEL.""" + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/elements/add", + data={"widget": "personnage", "forge_character": "male-adventurer"}, + follow_redirects=False, + ) + el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + _add_flow_node( + client, game, screen_id, node_type="action", action_type="jouer_animation_sprite", + target_element_id=el_id, data_value=json.dumps({"animation": "walk", "fps": 8, "loop": True}), + ) + + payload = screens.full_game_payload(game) + assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/male-adventurer/") + + client.post( + f"/game/{game}/elements/{el_id}/personnage", + data=json.dumps({"source": "forge", "forge_character": "zombie"}), + content_type="application/json", + ) + + payload = screens.full_game_payload(game) + assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/zombie/") def test_screen_edit_exposes_sprite_animation_editors(client, game):