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):