Refonte : vrai widget "Personnage" visuel (remplace l'UI de la Phase 7)
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:
co-authored by
Claude Sonnet 5
parent
d2c2f20b65
commit
9e076f8cf9
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
@@ -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, '"') + '">';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
function filterIconGallery(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user