Files
Forge-Engine/static/js/screen_edit/animation-timeline.js
T
williamandClaude Sonnet 5 9e076f8cf9
Build and deploy / test-python (push) Successful in 1m26s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Refonte : vrai widget "Personnage" visuel (remplace l'UI de la Phase 7)
L'utilisateur a testé la Phase 7 (sprites pilotés via un menu déroulant
+ zone de texte dans la logique de flow) et l'a rejetée à raison : ce
n'est pas comme ça qu'un moteur de jeu (Phaser, Unity) gère un
personnage. Cette refonte remplace tout le flux d'AUTEURING par un vrai
widget visuel — le moteur d'exécution de la Phase 7 (runSpriteAnimation,
activeSpriteAnimations, orientation, kind="sprite" de la Timeline) reste
inchangé.

Nouveau widget "personnage" (screens/widgets/registry.py) :
- Rendu serveur dédié (special_render, screens/rendering/render_personnage.py)
  qui affiche la pose "idle" dès le HTML généré — jamais une image
  cassée à configurer après coup.
- Toutes ses données (source Forge ou sprites propres au créateur, quel
  personnage/quelles animations) vivent dans une seule clé JSON
  _personnage_data (screens/rendering/personnage_data.py), sans aucune
  migration de schéma (même patron que c_clause_list).
- Nouvelle échappatoire "custom_panel", symétrique à "special_render" mais
  pour le panneau de propriétés : ce widget affiche une galerie/un import
  de sprites sur-mesure plutôt que les contrôles génériques.

Galerie visuelle de personnages Forge (VRAIES miniatures, pas un emoji) :
- Panneau gauche "🎭 Personnages" : pose un personnage déjà configuré,
  animé immédiatement.
- Panneau droit (propriétés) : change le personnage Forge de l'élément
  sélectionné, ou importe les animations d'un sprite personnalisé.
- static/js/screen_edit/personnage-preview.js : lance l'aperçu animé de
  CHAQUE personnage du canevas dès le chargement de la page et après
  toute sauvegarde — le cœur de la demande ("je dois voir ça bouger").

Sélecteur d'animation à miniatures (nœud de flow "Jouer une animation" et
clip de Timeline "sprite") : remplace le menu déroulant/la zone de texte
par une grille de vraies miniatures, scopée aux SEULES animations du
personnage réellement ciblé (ELEMENT_ANIMATIONS_MAP, résolu côté serveur
à partir des propriétés de cet élément précis) — jamais un catalogue
global. Le format stocké sur le nœud/le clip ({frames, fps, loop}) est
inchangé, seule l'UI d'édition change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:26:39 +02:00

405 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 {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.
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);
}
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 — 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;
fields.custom_keyframes = {
frames: ownerAnimations[animName] || [],
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 */ }
var frames = spriteData.frames || [];
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(); }