Corrige : une animation déjà posée dans la logique ignorait le changement de personnage
Build and deploy / test-python (push) Successful in 1m39s
Build and deploy / test-js (push) Successful in 7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-31 08:56:20 +02:00
co-authored by Claude Sonnet 5
parent 5ead091c75
commit 70c2b8df05
6 changed files with 112 additions and 39 deletions
+20 -1
View File
@@ -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;
+7 -5
View File
@@ -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');
+19 -23
View File
@@ -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) {
+10 -9
View File
@@ -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,
});