Ajoute une nouvelle action de flow "jouer_animation_sprite" qui joue une
séquence de PNG sur un élément image (cycle en boucle type marche, ou
une fois type saut) — réutilise target_element_id (déjà whitelisté) et
data_value en JSON {frames, fps, loop}, exactement le patron déjà établi
par "jouer_son" (Phase 6) et "alea" (Phase 2) : aucune nouvelle colonne,
aucune migration de schéma.
Ajoute une propriété d'élément "orientation" (Modifier un élément) pour
retourner un personnage en miroir (gauche/droite/bascule) sans nécessiter
une 2e feuille de sprites "vue de dos" — même patron que "surbrillance"/
"désactivé".
Étend aussi la Timeline d'animation existante (kind="sprite", aux côtés
de "animate_css"/"custom") pour qu'un personnage puisse animer tout seul
dès l'affichage de l'écran (ex. idle en boucle perpétuelle), pas
seulement en réaction à un événement — réutilise la colonne générique
custom_keyframes (JSON) et le réglage iteration_count déjà là pour la
boucle, aucune migration non plus. Les deux entrées (action de flow et
clip de timeline) partagent le même moteur côté client
(runSpriteAnimation()/activeSpriteAnimations dans static/js/play/
actions.js, indexé par nœud DOM plutôt que par id d'élément pour
supporter plusieurs instances d'un même écran-modèle animées
indépendamment).
Une bibliothèque de sprites Forge (2 personnages Kenney CC0, sous-
ensemble curé idle/marche/saut copié dans static/characters/) est
proposée dans l'éditeur, mais un créateur peut tout aussi bien utiliser
ses propres sprites uploadés (même route d'upload générique que le son
de la Phase 6).
Périmètre volontairement limité à ce qui a été demandé : pas d'avatar
modulable en couches (assemblage cheveux/haut/bas par le joueur),
écarté du plan initial à la demande de l'utilisateur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
384 lines
17 KiB
JavaScript
384 lines
17 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';
|
|
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';
|
|
}
|
|
|
|
// Phase 7 — même mécanisme que onSpriteLibraryCharacterChange()/
|
|
// applySpriteLibraryAnimation() dans flow-editor.js, pour le formulaire de
|
|
// clip de Timeline (DOM distinct, pas d'état partagé entre les deux formulaires).
|
|
function onAnimClipSpriteLibraryCharacterChange() {
|
|
var charSel = document.getElementById('anim-clip-sprite-library-character');
|
|
var animSel = document.getElementById('anim-clip-sprite-library-animation');
|
|
var slug = charSel.value;
|
|
animSel.innerHTML = '';
|
|
if (!slug || !SPRITE_LIBRARY[slug]) { animSel.style.display = 'none'; return; }
|
|
Object.keys(SPRITE_LIBRARY[slug].animations).forEach(function(name) {
|
|
var opt = document.createElement('option');
|
|
opt.value = name; opt.textContent = name;
|
|
animSel.appendChild(opt);
|
|
});
|
|
animSel.style.display = 'block';
|
|
applyAnimClipSpriteLibraryAnimation();
|
|
}
|
|
|
|
function applyAnimClipSpriteLibraryAnimation() {
|
|
var slug = document.getElementById('anim-clip-sprite-library-character').value;
|
|
var name = document.getElementById('anim-clip-sprite-library-animation').value;
|
|
if (!slug || !name || !SPRITE_LIBRARY[slug]) return;
|
|
document.getElementById('anim-clip-sprite-frames').value = SPRITE_LIBRARY[slug].animations[name].join('\n');
|
|
}
|
|
|
|
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 7 — clip.custom_keyframes stocke {frames, fps} en JSON pour un
|
|
// clip kind="sprite" (pas une liste d'images-clés CSS, voir ci-dessus).
|
|
var spriteData = {};
|
|
if (clip.kind === 'sprite' && clip.custom_keyframes) {
|
|
try { spriteData = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
|
}
|
|
document.getElementById('anim-clip-sprite-frames').value = (spriteData.frames || []).join('\n');
|
|
document.getElementById('anim-clip-sprite-fps').value = spriteData.fps || 8;
|
|
toggleAnimClipKind();
|
|
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 7 — {frames, fps} en JSON dans custom_keyframes (colonne
|
|
// générique déjà réutilisée par le kind "custom" ci-dessus pour ses
|
|
// images-clés) ; la boucle est déjà portée par iteration_count
|
|
// (1/2/3/infinite, ci-dessus) — aucun champ dédié nécessaire.
|
|
var frames = document.getElementById('anim-clip-sprite-frames').value
|
|
.split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
|
|
fields.custom_keyframes = {
|
|
frames: frames,
|
|
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(); }
|