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,
};
}
+39 -30
View File
@@ -512,6 +512,7 @@ function toggleFlowActionFields() {
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';
if (typeSel.value === 'jouer_animation_sprite') onSpriteTargetElementChange();
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';
@@ -538,31 +539,35 @@ 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);
// Phase 8 — sélecteur d'animation à miniatures RÉELLES, scopé au
// personnage réellement ciblé (ELEMENT_ANIMATIONS_MAP, injecté par
// screen_edit.html à partir des propriétés de CET élément précis — voir
// resolve_personnage_animations() côté serveur) — jamais un menu déroulant
// ni un catalogue global. Mirror de onFlowDefinitionChange() (précédent le
// plus proche pour "choisir A repeuple B").
function onSpriteTargetElementChange() {
var elSel = document.getElementById('nf-action-sprite-target-element');
var grid = document.getElementById('nf-action-sprite-animation-grid');
var nameField = document.getElementById('nf-action-sprite-animation-name');
grid.innerHTML = '';
nameField.value = '';
var elementId = elSel.value;
var animations = elementId ? (ELEMENT_ANIMATIONS_MAP[elementId] || {}) : {};
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';
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';
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() {
@@ -740,13 +745,17 @@ function submitNodeForm() {
// 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);
// Phase 8 — l'animation choisie (miniature cliquée, voir
// onSpriteTargetElementChange()) est résolue en liste de frames ICI,
// à l'enregistrement : le nœud stocké reste exactement {frames, fps,
// loop} (Phase 7, moteur d'exécution inchangé — voir runSpriteAnimation()
// dans static/js/play/actions.js), seule l'UI d'édition a changé.
var elementId = document.getElementById('nf-action-sprite-target-element').value;
var animName = document.getElementById('nf-action-sprite-animation-name').value;
var animations = elementId ? (ELEMENT_ANIMATIONS_MAP[elementId] || {}) : {};
payload.target_element_id = parseInt(elementId, 10);
payload.data_value = JSON.stringify({
frames: frames,
frames: animations[animName] || [],
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
loop: document.getElementById('nf-action-sprite-loop').checked,
});
-49
View File
@@ -70,55 +70,6 @@ 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']);
+111
View File
@@ -0,0 +1,111 @@
// ---------- Panneau de propriétés "🎭 Personnage" (Phase 8) ----------
// Panneau sur-mesure (custom_panel, voir registry.py) : PAS de soumission
// générique via #propsForm — chaque changement s'enregistre en AJAX vers
// POST /game/<slug>/elements/<id>/personnage (element_set_personnage_data.py),
// qui renvoie les animations résolues pour relancer immédiatement l'aperçu
// animé (voir personnage-preview.js) sans recharger la page.
function _personnageElementId() {
var body = document.getElementById('personnagePanelBody');
return body ? parseInt(body.dataset.elementId, 10) : null;
}
function _savePersonnageData(data) {
var elementId = _personnageElementId();
if (!elementId) return Promise.resolve();
return fetch('/game/' + GAME_SLUG + '/elements/' + elementId + '/personnage', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.animations) {
// Reste en phase avec le sélecteur d'animation du nœud de flow/clip
// de Timeline (voir flow-editor.js/animation-timeline.js), sans
// recharger la page.
ELEMENT_ANIMATIONS_MAP[elementId] = json.animations;
restartPersonnageIdlePreview(elementId, json.animations);
}
return json;
});
}
// ---------- Source Forge : changer de personnage ----------
function swapPersonnageCharacter(forgeCharacter) {
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (tile) {
tile.classList.toggle('selected', tile.getAttribute('onclick') === "swapPersonnageCharacter('" + forgeCharacter + "')");
});
_savePersonnageData({ source: 'forge', forge_character: forgeCharacter });
}
// ---------- Source personnalisée : animations importées ----------
function _collectPersonnageAnimations() {
var animations = {};
document.querySelectorAll('#personnageAnimationsList .personnageAnimRow').forEach(function (row) {
var name = row.querySelector('.personnageAnimName').value.trim();
if (!name) return;
var frames = Array.prototype.map.call(row.querySelectorAll('.personnageFrameThumb'), function (img) { return img.src; });
animations[name] = frames;
});
return animations;
}
function _saveCustomAnimations() {
return _savePersonnageData({ source: 'custom', animations: _collectPersonnageAnimations() });
}
function addPersonnageAnimation() {
var list = document.getElementById('personnageAnimationsList');
var row = document.createElement('div');
row.className = 'personnageAnimRow box';
row.style.marginBottom = '8px';
row.style.padding = '8px';
row.innerHTML =
'<div class="fieldRow" style="align-items:center; gap:6px;">' +
'<input type="text" class="input is-small personnageAnimName" placeholder="ex. idle, marche, saut" style="flex:1;">' +
'<button type="button" class="button is-small is-danger" onclick="removePersonnageAnimation(this)">🗑</button>' +
'</div>' +
'<div class="personnageAnimFrames" style="display:flex; flex-wrap:wrap; gap:4px; margin-top:6px;"></div>' +
'<input type="file" class="personnageFrameUpload input is-small" accept="image/*" multiple style="margin-top:6px;">' +
'<span class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>';
list.appendChild(row);
}
function removePersonnageAnimation(btn) {
btn.closest('.personnageAnimRow').remove();
_saveCustomAnimations();
}
// Délégation d'événements : les lignes d'animation sont ajoutées
// dynamiquement (addPersonnageAnimation ci-dessus), un bindage statique au
// chargement de la page ne les couvrirait pas.
document.addEventListener('change', function (ev) {
if (ev.target.classList.contains('personnageAnimName')) {
_saveCustomAnimations();
} else if (ev.target.classList.contains('personnageFrameUpload')) {
var input = ev.target;
if (!input.files.length) return;
var row = input.closest('.personnageAnimRow');
var framesWrap = row.querySelector('.personnageAnimFrames');
var status = row.querySelector('.uploadStatus');
status.textContent = 'Envoi en cours...';
var uploads = Array.prototype.map.call(input.files, function (file) {
var data = new FormData();
data.append('file', file);
return fetch('/game/' + GAME_SLUG + '/upload', { method: 'POST', body: data }).then(function (r) { return r.json(); });
});
Promise.all(uploads).then(function (results) {
results.forEach(function (json) {
if (!json.url) return;
var img = document.createElement('img');
img.className = 'personnageFrameThumb';
img.alt = '';
img.style.cssText = 'width:32px; height:32px; object-fit:contain; background:var(--panel2); border-radius:4px;';
img.src = json.url;
framesWrap.appendChild(img);
});
status.textContent = results.every(function (j) { return j.url; }) ? 'Fichier(s) envoyé(s) ✓' : "Échec de l'envoi pour certains fichiers.";
input.value = '';
_saveCustomAnimations();
});
}
});
@@ -0,0 +1,55 @@
// ---------- Aperçu animé immédiat dans l'éditeur (Phase 8) ----------
// Le cœur de la demande : un personnage posé sur la scène doit s'animer
// tout de suite dans le canevas, sans ouvrir la Timeline ni la logique de
// flow. static/js/play/actions.js (runSpriteAnimation) n'est PAS chargé
// dans l'éditeur — mirror allégé de son moteur, scopé à l'idle de chaque
// personnage du canevas (un seul intervalle actif par élément, comme le
// moteur de jeu).
var _personnageIdleIntervals = {};
function _stopPersonnageIdlePreview(elementId) {
if (_personnageIdleIntervals[elementId]) {
clearInterval(_personnageIdleIntervals[elementId]);
delete _personnageIdleIntervals[elementId];
}
}
function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
_stopPersonnageIdlePreview(elementId);
if (!frames || !frames.length) return;
targetEl.src = frames[0];
if (frames.length === 1) return;
var frameIndex = 0;
_personnageIdleIntervals[elementId] = setInterval(function () {
// Navigation pjax (voir pjax.js) : le canevas précédent est remplacé
// sans que ce script ne soit prévenu — s'auto-arrête dès que son
// élément a quitté le DOM plutôt que de tourner indéfiniment dans le
// vide.
if (!document.body.contains(targetEl)) { clearInterval(_personnageIdleIntervals[elementId]); return; }
frameIndex = (frameIndex + 1) % frames.length;
targetEl.src = frames[frameIndex];
}, 1000 / (fps || 8));
}
// Appelée après tout changement de personnage/animations (voir
// personnage-panel.js) pour relancer l'aperçu sans recharger la page.
function restartPersonnageIdlePreview(elementId, animations) {
var targetEl = document.querySelector('#canvas [data-element-id="' + elementId + '"][data-personnage="1"]');
if (!targetEl) return;
var frames = (animations && (animations.idle || Object.values(animations)[0])) || [];
_startPersonnageIdlePreview(targetEl, elementId, frames, 8);
}
function startAllPersonnagePreviews() {
document.querySelectorAll('#canvas [data-personnage="1"]').forEach(function (targetEl) {
var elementId = parseInt(targetEl.dataset.elementId, 10);
var animations = ELEMENT_ANIMATIONS_MAP[elementId];
var frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
// personnalisé sans frame) : garde l'image déjà posée par le rendu
// serveur (ou rien), pas d'intervalle à démarrer.
if (frames && frames.length > 1) _startPersonnageIdlePreview(targetEl, elementId, frames, 8);
});
}
startAllPersonnagePreviews();
+15
View File
@@ -192,6 +192,21 @@ function addIconElement(iconClass) {
form.requestSubmit();
}
// Phase 8 — galerie "🎭 Personnages" (panneau gauche) : mirror exact
// d'addIconElement() ci-dessus, "__custom__" pose un sprite personnalisé
// vide (voir element_add.py) dont les animations s'importent ensuite dans
// ses propriétés (panneau droit, personnage-panel.js).
function addPersonnageElement(forgeCharacter) {
var form = document.createElement('form');
form.method = 'post';
form.action = ELEMENT_ADD_URL;
form.innerHTML =
'<input type="hidden" name="widget" value="personnage">' +
'<input type="hidden" name="forge_character" value="' + forgeCharacter.replace(/"/g, '&quot;') + '">';
document.body.appendChild(form);
form.requestSubmit();
}
function filterIconGallery(query) {
var q = query.trim().toLowerCase();
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) {
+5
View File
@@ -470,6 +470,11 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.iconGalleryTile:hover{ border-color:var(--accent); color:var(--accent); }
.iconGalleryTile.dragging{ opacity:0.4; }
.iconGalleryTile.iconHidden{ display:none; }
.iconGalleryTile.selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent) inset; }
/* Phase 8 — galerie de personnages : réutilise .iconGallery/.iconGalleryTile
(grille + tuile), mais avec de VRAIES miniatures (<img>, pas un masque
SVG monochrome) — voir screen_edit.html et personnage-panel.js. */
.personnageThumb{ width:100%; height:100%; object-fit:contain; image-rendering:pixelated; }
.widgetBtnForm{ margin:0; }
.widgetBtn{
width:100%; display:flex; flex-direction:column; align-items:center; gap:4px;