Phase 7 : personnage animé par sprites (poses/images successives)
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

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>
This commit is contained in:
william
2026-08-30 19:18:07 +02:00
co-authored by Claude Sonnet 5
parent 76331f9285
commit d2c2f20b65
35 changed files with 519 additions and 3 deletions
@@ -9,6 +9,11 @@
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; }
@@ -165,6 +170,32 @@ 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) {
@@ -229,6 +260,14 @@ function openAnimClipForm(clip) {
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);
@@ -254,6 +293,18 @@ function saveAnimClipForm() {
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(); });
}
@@ -296,6 +347,22 @@ function previewAnimClip() {
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');