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
+68
View File
@@ -76,6 +76,19 @@ function applyElementProperty(targetEl, property, value) {
}
return;
}
if (property === 'orientation') {
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
// de dos" pour chaque sens de déplacement.
if (value === 'toggle') {
targetEl.classList.toggle('forgeFlipped');
} else if (value === 'gauche') {
targetEl.classList.add('forgeFlipped');
} else {
targetEl.classList.remove('forgeFlipped');
}
return;
}
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
@@ -227,7 +240,62 @@ function runActionNode(node) {
// Fire-and-forget : ne bloque jamais la suite du graphe.
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
return Promise.resolve();
} else if (node.action_type === 'jouer_animation_sprite' && node.target_element_id) {
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
if (targetEl) {
let spriteData = {};
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
runSpriteAnimation(targetEl, spriteData);
}
return Promise.resolve();
}
// 'rien' : aucune action volontairement.
return Promise.resolve();
}
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
// dans screens.js, qui sont par-écran/singleton) : plusieurs personnages
// peuvent animer indépendamment en même temps. Clé = le NŒUD DOM lui-même
// (pas element_id) : un clip posé sur un écran-modèle peut viser plusieurs
// instances à la fois (ex. chaque ligne d'un Répéteur utilisant ce modèle),
// toutes partageant le même element_id — les indexer par élément DOM réel
// évite qu'une nouvelle instance n'arrête l'animation d'une autre. Alimenté
// par deux entrées équivalentes — l'action de flow "jouer_animation_sprite"
// ci-dessus (événementiel) ET un clip de la Timeline d'animation de kind
// "sprite" (déclaratif, voir applyAnimationClip() dans screens.js) — les
// deux partagent ce même moteur, aucune logique dupliquée.
var activeSpriteAnimations = new Map();
function runSpriteAnimation(targetEl, spriteData) {
var frames = spriteData.frames || [];
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
var loop = !!spriteData.loop;
if (activeSpriteAnimations.has(targetEl)) {
clearInterval(activeSpriteAnimations.get(targetEl));
activeSpriteAnimations.delete(targetEl);
}
if (!frames.length) return;
var frameIndex = 0;
targetEl.src = frames[0];
if (frames.length === 1) return;
var intervalId = setInterval(function() {
frameIndex++;
if (frameIndex >= frames.length) {
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
frameIndex = 0;
}
targetEl.src = frames[frameIndex];
}, 1000 / fps);
activeSpriteAnimations.set(targetEl, intervalId);
}
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
// encapsule activeSpriteAnimations ici plutôt que de le manipuler
// directement depuis screens.js, même patron que runScreenTimerTriggers()
// dans triggers.js (l'appelant ne connaît jamais la structure interne).
function stopAllSpriteAnimations() {
activeSpriteAnimations.forEach(function(intervalId) { clearInterval(intervalId); });
activeSpriteAnimations.clear();
}
+14
View File
@@ -38,6 +38,11 @@ function showScreen(id) {
window.currentScreenId = id;
applyScreenAspect(el.dataset.aspect || '9/16');
}
// Phase 7 — nettoie les animations de sprites de l'écran quitté (sinon
// une boucle setInterval reste active sur un élément désormais caché,
// mirroring le nettoyage déjà fait pour activeTimerTriggerIntervals
// dans triggers.js).
stopAllSpriteAnimations();
runScreenShowTriggers(id);
runScreenTimerTriggers(id);
runAnimationTimeline(id);
@@ -141,6 +146,15 @@ function applyAnimationClip(clip) {
target.removeEventListener('animationend', handler);
});
}
} 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).
var spriteData = {};
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ }
spriteData.loop = infinite;
setTimeout(function() { runSpriteAnimation(target, spriteData); }, delay * 1000);
} else {
var animClass = 'animate__' + clip.animate_name;
target.style.setProperty('--animate-duration', duration + 's');
@@ -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');
+57 -2
View File
@@ -91,6 +91,8 @@ function nodeLabel(node) {
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'desactive') {
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'orientation') {
base += ' = ' + (ELEMENT_ORIENTATION_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
var vparts = node.element_value.split('||');
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
@@ -115,6 +117,12 @@ function nodeLabel(node) {
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
} else if (node.action_type === 'jouer_son') {
base += ' « ' + (node.data_value || '?') + ' »';
} else if (node.action_type === 'jouer_animation_sprite') {
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' : '') + ')';
} catch (e) { /* data_value invalide : pas de détail affiché */ }
}
return base;
}
@@ -500,8 +508,10 @@ function toggleFlowActionFields() {
var eventFields = document.getElementById('nf-action-event-fields');
var addRowFields = document.getElementById('nf-action-add-row-fields');
var soundFields = document.getElementById('nf-action-sound-fields');
var spriteFields = document.getElementById('nf-action-sprite-fields');
var isOnglet = typeSel.value === 'activer_onglet';
soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
spriteFields.style.display = typeSel.value === 'jouer_animation_sprite' ? 'block' : 'none';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
@@ -528,16 +538,44 @@ function updateDataValuePlaceholder(operationSelectId, valueInputId) {
valueInp.placeholder = opSel.value === 'alea' ? 'min,max (ex. 1,6)' : 'Valeur / montant';
}
// Phase 7 — bibliothèque de sprites Forge (SPRITE_LIBRARY, injecté par
// screen_edit.html) : choisir un personnage puis une de ses animations
// pré-remplit la zone de frames — un créateur qui préfère ses propres
// sprites laisse simplement ce sélecteur sur "— Choisir —" et remplit/
// uploade directement dans la zone de frames.
function onSpriteLibraryCharacterChange() {
var charSel = document.getElementById('nf-action-sprite-library-character');
var animSel = document.getElementById('nf-action-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';
applySpriteLibraryAnimation();
}
function applySpriteLibraryAnimation() {
var slug = document.getElementById('nf-action-sprite-library-character').value;
var name = document.getElementById('nf-action-sprite-library-animation').value;
if (!slug || !name || !SPRITE_LIBRARY[slug]) return;
document.getElementById('nf-action-sprite-frames').value = SPRITE_LIBRARY[slug].animations[name].join('\n');
}
function toggleFlowElementPropertyValue() {
var propSel = document.getElementById('nf-action-element-property');
var visSel = document.getElementById('nf-action-element-value-visibility');
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
var orientationSel = document.getElementById('nf-action-element-value-orientation');
var colorInp = document.getElementById('nf-action-element-value-color');
var numInp = document.getElementById('nf-action-element-value-number');
var textInp = document.getElementById('nf-action-element-value-text');
var toggleRow = document.getElementById('nf-action-element-toggle-row');
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
[visSel, surbrillanceSel, desactiveSel, orientationSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
if (propSel.value === 'visibilite') {
visSel.style.display = 'block';
toggleRow.style.display = 'none';
@@ -550,6 +588,10 @@ function toggleFlowElementPropertyValue() {
desactiveSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'orientation') {
orientationSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'contenu') {
textInp.style.display = 'block';
toggleRow.style.display = 'none';
@@ -646,13 +688,15 @@ function submitNodeForm() {
var prop = document.getElementById('nf-action-element-property').value;
payload.element_property = prop;
var isColorProp = prop === 'bg_color' || prop === 'border_color';
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && document.getElementById('nf-action-element-toggle').checked;
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && prop !== 'orientation' && document.getElementById('nf-action-element-toggle').checked;
if (prop === 'visibilite') {
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
} else if (prop === 'surbrillance') {
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
} else if (prop === 'desactive') {
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
} else if (prop === 'orientation') {
payload.element_value = document.getElementById('nf-action-element-value-orientation').value;
} else if (prop === 'contenu') {
payload.element_value = document.getElementById('nf-action-element-value-text').value;
} else {
@@ -695,6 +739,17 @@ function submitNodeForm() {
// sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle
// colonne dédiée.
payload.data_value = document.getElementById('nf-action-sound-url').value;
} else if (actionType === 'jouer_animation_sprite') {
// Phase 7 — réutilise target_element_id (déjà whitelisté) +
// data_value en JSON {frames, fps, loop}, même patron que jouer_son.
payload.target_element_id = parseInt(document.getElementById('nf-action-sprite-target-element').value, 10);
var frames = document.getElementById('nf-action-sprite-frames').value
.split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
payload.data_value = JSON.stringify({
frames: frames,
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
loop: document.getElementById('nf-action-sprite-loop').checked,
});
}
}
+49
View File
@@ -70,6 +70,55 @@ function initBuilderPanel() {
});
});
// Phase 7 — sprites propres au créateur : contrairement aux autres
// champs d'upload (qui REMPLACENT la valeur d'un champ texte unique),
// chaque fichier envoyé ici AJOUTE une ligne à la zone de frames — un
// personnage animé a besoin de plusieurs images, pas d'une seule.
var spriteUploadInput = document.getElementById('nf-action-sprite-upload');
if (spriteUploadInput) {
spriteUploadInput.addEventListener('change', function () {
if (!spriteUploadInput.files.length) return;
var status = document.getElementById('nf-action-sprite-upload-status');
var framesField = document.getElementById('nf-action-sprite-frames');
status.textContent = 'Envoi en cours...';
var data = new FormData();
data.append('file', spriteUploadInput.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) {
framesField.value = framesField.value ? (framesField.value + '\n' + json.url) : json.url;
status.textContent = 'Fichier envoyé ✓';
} else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
}
// Phase 7 — même mécanisme d'upload que nf-action-sprite-upload
// ci-dessus (ajoute une ligne plutôt que de remplacer), pour le
// formulaire de clip de la Timeline d'animation.
var animClipSpriteUploadInput = document.getElementById('anim-clip-sprite-upload');
if (animClipSpriteUploadInput) {
animClipSpriteUploadInput.addEventListener('change', function () {
if (!animClipSpriteUploadInput.files.length) return;
var status = document.getElementById('anim-clip-sprite-upload-status');
var framesField = document.getElementById('anim-clip-sprite-frames');
status.textContent = 'Envoi en cours...';
var data = new FormData();
data.append('file', animClipSpriteUploadInput.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) {
framesField.value = framesField.value ? (framesField.value + '\n' + json.url) : json.url;
status.textContent = 'Fichier envoyé ✓';
} else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
}
bindPropsAutosave();
bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);