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
+19 -1
View File
@@ -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 <div
# class="playScreen"> (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,
}
+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,
});
+37
View File
@@ -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):