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>
401 lines
18 KiB
JavaScript
401 lines
18 KiB
JavaScript
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
|
|
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
|
|
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
|
|
// panneau rétractable/redimensionnable que "Logique de la scène"
|
|
// ci-dessus, positionné juste en dessous. ----------
|
|
|
|
// ANIM_CLIPS/ANIMATE_CSS_LABELS/GAME_SLUG : voir le petit <script> inline
|
|
// en tête de screen_edit.html (données Jinja).
|
|
var ANIM_SCREEN_ID = SCREEN_ID;
|
|
var ANIM_PX_PER_SEC = 60;
|
|
var animCurrentClipId = null;
|
|
// Phase 7 — aperçu d'un clip "sprite" dans l'éditeur (voir previewAnimClip
|
|
// ci-dessous) : un intervalle à la fois suffit ici (contrairement au
|
|
// moteur de jeu, static/js/play/actions.js, PAS chargé sur cette page —
|
|
// runSpriteAnimation()/activeSpriteAnimations n'existent que côté jeu).
|
|
var animPreviewSpriteInterval = null;
|
|
|
|
function animUrl(path) { return "/game/" + GAME_SLUG + path; }
|
|
|
|
function animClipLabel(clip) {
|
|
if (clip.kind === 'custom') return '🎨 Personnalisée';
|
|
if (clip.kind === 'sprite') return '🏃 Sprite';
|
|
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name);
|
|
}
|
|
|
|
function renderAnimTimeline() {
|
|
var lanesWrap = document.getElementById('animTracks');
|
|
var ticksWrap = document.getElementById('animRuler');
|
|
if (!lanesWrap || !ticksWrap) return;
|
|
lanesWrap.innerHTML = '';
|
|
ticksWrap.innerHTML = '';
|
|
|
|
var maxTime = ANIM_CLIPS.reduce(function(m, c) { return Math.max(m, (c.start_time || 0) + (c.duration || 1)); }, 5);
|
|
var totalSeconds = Math.ceil(maxTime) + 2;
|
|
var totalWidth = totalSeconds * ANIM_PX_PER_SEC;
|
|
|
|
var spacer = document.createElement('div');
|
|
spacer.className = 'animRulerSpacer';
|
|
ticksWrap.appendChild(spacer);
|
|
var ticksLane = document.createElement('div');
|
|
ticksLane.className = 'animRulerTicksWrap';
|
|
ticksLane.style.width = totalWidth + 'px';
|
|
for (var s = 0; s <= totalSeconds; s++) {
|
|
var tick = document.createElement('div');
|
|
tick.className = 'animRulerTick';
|
|
tick.style.left = (s * ANIM_PX_PER_SEC) + 'px';
|
|
tick.textContent = s + 's';
|
|
ticksLane.appendChild(tick);
|
|
}
|
|
ticksWrap.appendChild(ticksLane);
|
|
|
|
var elementIds = [];
|
|
ANIM_CLIPS.forEach(function(c) { if (elementIds.indexOf(c.element_id) === -1) elementIds.push(c.element_id); });
|
|
|
|
if (!elementIds.length) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'hint';
|
|
empty.style.padding = '10px 0';
|
|
empty.textContent = "Aucun clip pour l'instant — choisis un élément ci-dessus et clique « Ajouter un clip ».";
|
|
lanesWrap.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
elementIds.forEach(function(elId) {
|
|
var track = document.createElement('div');
|
|
track.className = 'animTrack';
|
|
|
|
var label = document.createElement('div');
|
|
label.className = 'animTrackLabel';
|
|
label.textContent = ELEMENTS_LABELS[elId] || ('elt-' + elId);
|
|
track.appendChild(label);
|
|
|
|
var lane = document.createElement('div');
|
|
lane.className = 'animTrackLane';
|
|
lane.style.width = totalWidth + 'px';
|
|
|
|
ANIM_CLIPS.filter(function(c) { return c.element_id === elId; }).forEach(function(clip) {
|
|
var block = document.createElement('div');
|
|
block.className = 'animClip';
|
|
block.style.left = ((clip.start_time || 0) * ANIM_PX_PER_SEC) + 'px';
|
|
block.style.width = Math.max(24, (clip.duration || 1) * ANIM_PX_PER_SEC) + 'px';
|
|
block.textContent = animClipLabel(clip);
|
|
block.dataset.clipId = clip.id;
|
|
block.addEventListener('mousedown', function(e) { onAnimClipMouseDown(e, clip.id); });
|
|
var handle = document.createElement('div');
|
|
handle.className = 'animClipResizeHandle';
|
|
handle.addEventListener('mousedown', function(e) { onAnimClipResizeMouseDown(e, clip.id); });
|
|
block.appendChild(handle);
|
|
lane.appendChild(block);
|
|
});
|
|
|
|
track.appendChild(lane);
|
|
lanesWrap.appendChild(track);
|
|
});
|
|
}
|
|
|
|
function onAnimClipMouseDown(e, clipId) {
|
|
if (e.target.classList.contains('animClipResizeHandle')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
|
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
|
var startX = e.clientX;
|
|
var startLeft = parseFloat(block.style.left);
|
|
var moved = false;
|
|
function onMove(ev) {
|
|
var dx = ev.clientX - startX;
|
|
if (Math.abs(dx) > 3) moved = true;
|
|
block.style.left = Math.max(0, startLeft + dx) + 'px';
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
if (moved) {
|
|
var newStart = Math.round((parseFloat(block.style.left) / ANIM_PX_PER_SEC) * 10) / 10;
|
|
clip.start_time = newStart;
|
|
updateAnimClip(clipId, { start_time: newStart });
|
|
} else {
|
|
openAnimClipForm(clip);
|
|
}
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function onAnimClipResizeMouseDown(e, clipId) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
|
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
|
var startX = e.clientX;
|
|
var startWidth = parseFloat(block.style.width);
|
|
function onMove(ev) {
|
|
var dx = ev.clientX - startX;
|
|
block.style.width = Math.max(10, startWidth + dx) + 'px';
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
var newDuration = Math.round((parseFloat(block.style.width) / ANIM_PX_PER_SEC) * 10) / 10;
|
|
clip.duration = newDuration;
|
|
updateAnimClip(clipId, { duration: newDuration });
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function updateAnimClip(clipId, fields) {
|
|
return fetch(animUrl('/animations/clips/' + clipId + '/update'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields)
|
|
}).then(function(r) { return r.json(); }).then(function(updated) {
|
|
var idx = ANIM_CLIPS.findIndex(function(c) { return c.id === updated.id; });
|
|
if (idx > -1) ANIM_CLIPS[idx] = updated;
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
function addAnimTrack() {
|
|
var elId = parseInt(document.getElementById('anim-new-track-element').value, 10);
|
|
fetch(animUrl('/screens/' + ANIM_SCREEN_ID + '/animations/clips/add'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ element_id: elId, kind: 'animate_css', animate_name: 'fadeIn', start_time: 0, duration: 1 }),
|
|
}).then(function(r) { return r.json(); }).then(function(clip) {
|
|
ANIM_CLIPS.push(clip);
|
|
renderAnimTimeline();
|
|
openAnimClipForm(clip);
|
|
});
|
|
}
|
|
|
|
function toggleAnimClipKind() {
|
|
var kind = document.getElementById('anim-clip-kind').value;
|
|
document.getElementById('anim-clip-animate-css-fields').style.display = kind === 'animate_css' ? 'block' : 'none';
|
|
document.getElementById('anim-clip-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
|
|
document.getElementById('anim-clip-sprite-fields').style.display = kind === 'sprite' ? 'block' : 'none';
|
|
if (kind === 'sprite') populateAnimClipSpriteGrid();
|
|
}
|
|
|
|
// Phase 8 — mirror de onSpriteTargetElementChange() (flow-editor.js), mais
|
|
// scopé au personnage PROPRIÉTAIRE de ce clip (clip.element_id, fixé à la
|
|
// création — voir addAnimTrack()) : pas de sélecteur d'élément séparé ici,
|
|
// contrairement au nœud de flow (une action peut viser n'importe quel
|
|
// personnage, un clip appartient à un seul élément dès sa création).
|
|
function populateAnimClipSpriteGrid(preselectedName) {
|
|
var grid = document.getElementById('anim-clip-sprite-animation-grid');
|
|
var nameField = document.getElementById('anim-clip-sprite-animation-name');
|
|
var noAnimHint = document.getElementById('anim-clip-sprite-no-animations');
|
|
grid.innerHTML = '';
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
|
var animations = clip ? (ELEMENT_ANIMATIONS_MAP[clip.element_id] || {}) : {};
|
|
var hasAnimations = Object.keys(animations).length > 0;
|
|
noAnimHint.style.display = hasAnimations ? 'none' : 'block';
|
|
Object.keys(animations).forEach(function(animName) {
|
|
var frames = animations[animName];
|
|
if (!frames.length) return;
|
|
var tile = document.createElement('button');
|
|
tile.type = 'button';
|
|
tile.className = 'iconGalleryTile' + (animName === preselectedName ? ' selected' : '');
|
|
tile.title = animName;
|
|
tile.innerHTML = '<img src="' + frames[0] + '" alt="" class="personnageThumb">';
|
|
tile.onclick = function() {
|
|
grid.querySelectorAll('.iconGalleryTile').forEach(function(t) { t.classList.remove('selected'); });
|
|
tile.classList.add('selected');
|
|
nameField.value = animName;
|
|
};
|
|
grid.appendChild(tile);
|
|
});
|
|
if (preselectedName && animations[preselectedName]) nameField.value = preselectedName;
|
|
}
|
|
|
|
function addAnimKeyframeRow(percent, cssText) {
|
|
var wrap = document.getElementById('anim-clip-keyframes');
|
|
var row = document.createElement('div');
|
|
row.className = 'fieldRow animKeyframeRow';
|
|
var percentInput = document.createElement('input');
|
|
percentInput.type = 'number'; percentInput.min = '0'; percentInput.max = '100'; percentInput.step = '1';
|
|
percentInput.className = 'anim-kf-percent'; percentInput.style.flex = '0 0 70px';
|
|
percentInput.value = percent != null ? percent : 0;
|
|
var cssInput = document.createElement('textarea');
|
|
cssInput.className = 'anim-kf-css'; cssInput.style.flex = '1'; cssInput.rows = 2;
|
|
cssInput.placeholder = 'ex. opacity: 0; transform: scale(0.5);';
|
|
cssInput.value = cssText || '';
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button'; removeBtn.className = 'smallBtn danger';
|
|
removeBtn.textContent = '✕';
|
|
removeBtn.onclick = function() { row.remove(); };
|
|
row.appendChild(percentInput);
|
|
row.appendChild(cssInput);
|
|
row.appendChild(removeBtn);
|
|
wrap.appendChild(row);
|
|
}
|
|
|
|
function collectAnimKeyframes() {
|
|
var rows = document.querySelectorAll('#anim-clip-keyframes .animKeyframeRow');
|
|
var keyframes = [];
|
|
rows.forEach(function(row) {
|
|
var percent = parseFloat(row.querySelector('.anim-kf-percent').value) || 0;
|
|
var cssText = row.querySelector('.anim-kf-css').value;
|
|
var props = {};
|
|
cssText.split(';').forEach(function(decl) {
|
|
var parts = decl.split(':');
|
|
if (parts.length === 2) {
|
|
var prop = parts[0].trim(), val = parts[1].trim();
|
|
if (prop && val) props[prop] = val;
|
|
}
|
|
});
|
|
keyframes.push({ percent: percent, props: props });
|
|
});
|
|
return keyframes;
|
|
}
|
|
|
|
function openAnimClipForm(clip) {
|
|
animCurrentClipId = clip.id;
|
|
document.getElementById('animClipForm').style.display = 'block';
|
|
document.getElementById('animClipFormTitle').textContent = 'Clip — ' + (ELEMENTS_LABELS[clip.element_id] || ('elt-' + clip.element_id));
|
|
document.getElementById('anim-clip-kind').value = clip.kind || 'animate_css';
|
|
document.getElementById('anim-clip-animate-name').value = clip.animate_name || 'fadeIn';
|
|
document.getElementById('anim-clip-start').value = clip.start_time || 0;
|
|
document.getElementById('anim-clip-duration').value = clip.duration || 1;
|
|
document.getElementById('anim-clip-delay').value = clip.delay || 0;
|
|
document.getElementById('anim-clip-easing').value = clip.easing || 'ease';
|
|
document.getElementById('anim-clip-iteration').value = clip.iteration_count || '1';
|
|
document.getElementById('anim-clip-keyframes').innerHTML = '';
|
|
var keyframes = [];
|
|
if (clip.custom_keyframes) {
|
|
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
|
}
|
|
if (!keyframes.length) keyframes = [{ percent: 0, props: { opacity: '0' } }, { percent: 100, props: { opacity: '1' } }];
|
|
keyframes.forEach(function(k) {
|
|
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 {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') populateAnimClipSpriteGrid(spriteData.animation);
|
|
document.querySelectorAll('.animClip').forEach(function(b) {
|
|
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
|
});
|
|
}
|
|
|
|
function closeAnimClipForm() {
|
|
animCurrentClipId = null;
|
|
document.getElementById('animClipForm').style.display = 'none';
|
|
document.querySelectorAll('.animClip').forEach(function(b) { b.classList.remove('selected'); });
|
|
}
|
|
|
|
function saveAnimClipForm() {
|
|
if (!animCurrentClipId) return Promise.resolve();
|
|
var kind = document.getElementById('anim-clip-kind').value;
|
|
var fields = {
|
|
kind: kind,
|
|
animate_name: document.getElementById('anim-clip-animate-name').value,
|
|
start_time: parseFloat(document.getElementById('anim-clip-start').value) || 0,
|
|
duration: parseFloat(document.getElementById('anim-clip-duration').value) || 1,
|
|
delay: parseFloat(document.getElementById('anim-clip-delay').value) || 0,
|
|
easing: document.getElementById('anim-clip-easing').value,
|
|
iteration_count: document.getElementById('anim-clip-iteration').value,
|
|
};
|
|
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
|
if (kind === 'sprite') {
|
|
// 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 = {
|
|
animation: document.getElementById('anim-clip-sprite-animation-name').value,
|
|
fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8,
|
|
};
|
|
}
|
|
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
|
}
|
|
|
|
function deleteAnimClipForm() {
|
|
if (!animCurrentClipId) return;
|
|
if (!confirm('Supprimer ce clip ?')) return;
|
|
var id = animCurrentClipId;
|
|
fetch(animUrl('/animations/clips/' + id + '/delete'), { method: 'POST' }).then(function() {
|
|
ANIM_CLIPS = ANIM_CLIPS.filter(function(c) { return c.id !== id; });
|
|
closeAnimClipForm();
|
|
renderAnimTimeline();
|
|
});
|
|
}
|
|
|
|
function previewAnimClip() {
|
|
if (!animCurrentClipId) return;
|
|
saveAnimClipForm().then(function() {
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
|
if (!clip) return;
|
|
var target = document.querySelector('#canvas [data-anim-target="' + clip.element_id + '"]');
|
|
if (!target) return;
|
|
var duration = clip.duration || 1, delay = clip.delay || 0;
|
|
var iterationCount = clip.iteration_count || '1';
|
|
if (clip.kind === 'custom') {
|
|
var keyframes = [];
|
|
try { keyframes = JSON.parse(clip.custom_keyframes || '[]'); } catch (e) { /* ignore */ }
|
|
var steps = keyframes.map(function(k) {
|
|
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
|
return k.percent + '% { ' + props + ' }';
|
|
}).join(' ');
|
|
var styleTag = document.getElementById('animEditorPreviewStyle');
|
|
if (!styleTag) {
|
|
styleTag = document.createElement('style');
|
|
styleTag.id = 'animEditorPreviewStyle';
|
|
document.head.appendChild(styleTag);
|
|
}
|
|
styleTag.textContent = '@keyframes animEditorPreview { ' + steps + ' }';
|
|
target.style.animation = 'animEditorPreview ' + duration + 's ' + (clip.easing || 'ease') + ' ' + delay + 's ' + iterationCount;
|
|
target.addEventListener('animationend', function handler() {
|
|
target.style.animation = '';
|
|
target.removeEventListener('animationend', handler);
|
|
});
|
|
} else if (clip.kind === 'sprite') {
|
|
var spriteData = {};
|
|
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* ignore */ }
|
|
// 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) {
|
|
var frameIndex = 0;
|
|
target.src = frames[0];
|
|
if (frames.length > 1) {
|
|
animPreviewSpriteInterval = setInterval(function() {
|
|
frameIndex = (frameIndex + 1) % frames.length;
|
|
target.src = frames[frameIndex];
|
|
}, 1000 / fps);
|
|
}
|
|
}
|
|
} 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);
|
|
target.addEventListener('animationend', function handler() {
|
|
target.classList.remove('animate__animated', animClass);
|
|
target.removeEventListener('animationend', handler);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
|
|
// panneau de logique — voir le commentaire Jinja au-dessus de .builderTabs.
|
|
var HAS_ANIM_PANEL = !!document.getElementById('animTabPanel');
|
|
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
|