Refonte : vrai widget "Personnage" visuel (remplace l'UI de la Phase 7)
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

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>
This commit is contained in:
william
2026-08-30 20:26:39 +02:00
co-authored by Claude Sonnet 5
parent d2c2f20b65
commit 9e076f8cf9
21 changed files with 660 additions and 159 deletions
+53 -32
View File
@@ -19,6 +19,7 @@ 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);
}
@@ -171,31 +172,39 @@ function toggleAnimClipKind() {
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 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);
// 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);
});
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');
if (preselectedName && animations[preselectedName]) nameField.value = preselectedName;
}
function addAnimKeyframeRow(percent, cssText) {
@@ -260,15 +269,25 @@ 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).
// 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-frames').value = (spriteData.frames || []).join('\n');
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);
});
@@ -294,14 +313,16 @@ function saveAnimClipForm() {
};
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);
// 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: frames,
frames: ownerAnimations[animName] || [],
fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8,
};
}