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
@@ -10,4 +10,5 @@ from . import (
|
||||
element_duplicate,
|
||||
element_rename,
|
||||
element_tab_add,
|
||||
element_set_personnage_data,
|
||||
)
|
||||
|
||||
@@ -24,4 +24,15 @@ def element_add(slug, screen_id):
|
||||
# défaut à la création plutôt que de repartir d'une icône neutre que
|
||||
# l'utilisateur devrait ensuite changer manuellement.
|
||||
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
|
||||
if widget == "personnage":
|
||||
# Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement()
|
||||
# dans tree-panels.js) : remplace le personnage Forge par défaut
|
||||
# (male-adventurer, voir default_style_for_widget.py) par celui
|
||||
# réellement cliqué, ou bascule sur une source "custom" vide si la
|
||||
# tuile "➕ Sprite personnalisé" a été choisie.
|
||||
forge_character = request.form.get("forge_character")
|
||||
if forge_character == "__custom__":
|
||||
screens.set_personnage_data(slug, new_id, {"source": "custom", "animations": {}})
|
||||
elif forge_character and forge_character in screens.SPRITE_LIBRARY:
|
||||
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
|
||||
def element_set_personnage_data(slug, element_id):
|
||||
"""Change le personnage Forge d'un élément déjà posé (galerie du
|
||||
panneau de propriétés), ou met à jour ses animations personnalisées
|
||||
(import de frames) — voir personnage-panel.js. Renvoie les animations
|
||||
résolues pour que le client relance immédiatement l'aperçu animé sans
|
||||
recharger la page (voir personnage-preview.js)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
el = screens.get_element(slug, element_id)
|
||||
if not el:
|
||||
return jsonify({"error": "élément introuvable"}), 404
|
||||
screens.set_personnage_data(slug, element_id, body)
|
||||
el = screens.get_element(slug, element_id)
|
||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
|
||||
@@ -19,6 +19,7 @@ def screen_edit(slug, screen_id):
|
||||
element_types = screens.list_element_types(slug)
|
||||
selected_id = request.args.get("selected", type=int)
|
||||
selected_controls = None
|
||||
personnage_data = None
|
||||
if selected_id:
|
||||
selected_el = next((e for e in elements if e["id"] == selected_id), None)
|
||||
if selected_el:
|
||||
@@ -28,6 +29,11 @@ def screen_edit(slug, screen_id):
|
||||
if owning_type:
|
||||
template_definition_id = owning_type.get("bound_definition_id")
|
||||
selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id)
|
||||
if selected_el.get("widget") == "personnage":
|
||||
# Phase 8 — panneau de propriétés sur-mesure (custom_panel,
|
||||
# voir registry.py) : pas de controls génériques à afficher,
|
||||
# juste la source (Forge/personnalisée) et son contenu.
|
||||
personnage_data = screens.resolve_personnage_data(selected_el)
|
||||
children_of = {}
|
||||
for el in elements:
|
||||
if el.get("parent_id"):
|
||||
@@ -51,6 +57,12 @@ def screen_edit(slug, screen_id):
|
||||
flow_edges = screens.list_flow_edges(slug, screen_id)
|
||||
elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements}
|
||||
screens_map = {s["id"]: s["name"] for s in all_screens}
|
||||
# Phase 8 — sélecteur d'animation à miniatures (nœud de flow "Jouer une
|
||||
# animation" / clip de Timeline "sprite") : scopé aux SEULES animations
|
||||
# de l'élément personnage réellement ciblé, jamais un catalogue global.
|
||||
element_animations_map = {
|
||||
el["id"]: screens.resolve_personnage_animations(el) for el in elements if el.get("widget") == "personnage"
|
||||
}
|
||||
animation_clips = screens.list_animation_clips(slug, screen_id)
|
||||
global_variables = db.list_global_variables(slug)
|
||||
custom_events = db.list_custom_events(slug)
|
||||
@@ -76,6 +88,8 @@ def screen_edit(slug, screen_id):
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
sprite_library=screens.SPRITE_LIBRARY,
|
||||
sprite_library_json=json.dumps(screens.SPRITE_LIBRARY),
|
||||
personnage_data=personnage_data,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
data_operations=screens.DATA_OPERATIONS,
|
||||
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
||||
definitions=definitions, definitions_json=json.dumps(definitions_data),
|
||||
|
||||
@@ -63,6 +63,7 @@ from .flow.constants import (
|
||||
|
||||
from .rendering.render_element_html import render_element_html
|
||||
from .rendering.trigger_for import _trigger_for
|
||||
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
|
||||
|
||||
from .screens_repo.ensure_schema import ensure_schema
|
||||
from .screens_repo.list_screens import list_screens
|
||||
@@ -87,6 +88,7 @@ from .elements.delete_element import delete_element
|
||||
from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element
|
||||
from .elements.duplicate_element import duplicate_element
|
||||
from .elements.rename_element import rename_element
|
||||
from .elements.set_personnage_data import set_personnage_data
|
||||
from .elements.element_label import element_label
|
||||
|
||||
from .element_types.list_element_types import list_element_types
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def set_personnage_data(slug, element_id, data):
|
||||
"""Fusionne _personnage_data (voir screens/rendering/personnage_data.py)
|
||||
dans les attributs déjà stockés de cet élément — remplace la source
|
||||
(Forge/personnalisée) et son contenu (personnage choisi, ou animations
|
||||
importées) en un seul appel, jamais les autres attributs de l'élément."""
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT attributes FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return
|
||||
attributes = json.loads(row["attributes"] or "{}")
|
||||
attributes["_personnage_data"] = json.dumps(data)
|
||||
conn.execute("UPDATE _screen_elements SET attributes = ? WHERE id = ?", (json.dumps(attributes), element_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,39 @@
|
||||
import json
|
||||
|
||||
from ..labels.sprite_library import SPRITE_LIBRARY
|
||||
|
||||
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
||||
|
||||
|
||||
def resolve_personnage_data(el):
|
||||
"""Décode attributes._personnage_data (voir set_personnage_data.py) en
|
||||
dict Python, jamais None — un attribut absent/corrompu retombe sur le
|
||||
défaut posé à la création (default_style_for_widget.py)."""
|
||||
raw = (el.get("attributes") or {}).get("_personnage_data")
|
||||
if not raw:
|
||||
return dict(_DEFAULT_PERSONNAGE_DATA)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return dict(_DEFAULT_PERSONNAGE_DATA)
|
||||
return data if isinstance(data, dict) else dict(_DEFAULT_PERSONNAGE_DATA)
|
||||
|
||||
|
||||
def resolve_personnage_animations(el):
|
||||
"""{nom_animation: [urls...]} pour cet élément personnage, quelle que
|
||||
soit sa source — Forge (SPRITE_LIBRARY) ou sprites propres au créateur
|
||||
(ses propres frames importées, voir personnage-panel.js)."""
|
||||
data = resolve_personnage_data(el)
|
||||
if data.get("source") == "custom":
|
||||
return data.get("animations") or {}
|
||||
character = SPRITE_LIBRARY.get(data.get("forge_character"))
|
||||
return character["animations"] if character else {}
|
||||
|
||||
|
||||
def resolve_personnage_idle_frame(el):
|
||||
"""Première image de l'animation "idle" (ou, à défaut, de la première
|
||||
animation disponible) — utilisée pour le rendu serveur ET l'aperçu
|
||||
animé de l'éditeur, jamais vide tant qu'au moins une frame existe."""
|
||||
animations = resolve_personnage_animations(el)
|
||||
frames = animations.get("idle") or (next(iter(animations.values())) if animations else None)
|
||||
return frames[0] if frames else ""
|
||||
@@ -15,6 +15,7 @@ from .render_jauge import _render_jauge
|
||||
from .render_overlay import _render_overlay
|
||||
from .render_onglets import _render_onglets
|
||||
from .render_icone import _render_icone
|
||||
from .render_personnage import _render_personnage
|
||||
from .resolve_bound_row import _resolve_bound_row_ctx
|
||||
from .visibility_condition import is_element_visible
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
@@ -105,6 +106,8 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
|
||||
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "icone":
|
||||
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "personnage":
|
||||
return _mark(_render_personnage(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
|
||||
tag = meta.get("tag")
|
||||
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .personnage_data import resolve_personnage_idle_frame
|
||||
|
||||
|
||||
def _render_personnage(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Widget Personnage — un <img> qui affiche sa pose "idle" dès le rendu
|
||||
SERVEUR (aperçu correct même sans JS), animée ensuite en boucle par
|
||||
static/js/screen_edit/personnage-preview.js dans l'éditeur, ou par une
|
||||
action de flow/un clip de Timeline en mode jouable (voir
|
||||
runSpriteAnimation() dans static/js/play/actions.js — moteur inchangé,
|
||||
seul l'auteuring dans l'éditeur diffère). data-personnage="1" marque
|
||||
cet élément pour la prévisualisation animée de l'éditeur."""
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
attrs["data-personnage"] = "1"
|
||||
idle_frame = resolve_personnage_idle_frame(el)
|
||||
if idle_frame:
|
||||
attrs["src"] = idle_frame
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
return f"<img {_attr_string(attrs, style)}>"
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from .widget_meta import widget_meta
|
||||
|
||||
# Réglages "par côté" (padding/margin/bordure individuels) : contrairement
|
||||
@@ -108,4 +110,14 @@ def default_style_and_attributes(widget_key):
|
||||
# Visibilité → Rendre visible" pour l'ouvrir — cohérent avec
|
||||
# "fermeture manuelle uniquement".
|
||||
style["display"] = "none"
|
||||
|
||||
if widget_key == "personnage":
|
||||
# Phase 8 — un personnage fraîchement posé est DÉJÀ configuré avec
|
||||
# un personnage Forge par défaut (voir screens/rendering/
|
||||
# personnage_data.py) plutôt que de démarrer vide : il s'affiche et
|
||||
# s'anime immédiatement dans l'éditeur (voir static/js/screen_edit/
|
||||
# personnage-preview.js), jamais une image cassée qu'il faudrait
|
||||
# d'abord configurer. Le tuile de galerie cliquée (voir element_add.py)
|
||||
# remplace ce défaut par le personnage réellement choisi juste après.
|
||||
attributes["_personnage_data"] = json.dumps({"source": "forge", "forge_character": "male-adventurer"})
|
||||
return attributes, style
|
||||
|
||||
@@ -315,6 +315,23 @@ WIDGETS = {
|
||||
"w": 60, "h": 40, "default_content": "",
|
||||
"controls": BULMA_TABS_CONTROLS,
|
||||
},
|
||||
"personnage": {
|
||||
# Widget Personnage (Phase 8) — un vrai objet visuel, pas une image
|
||||
# générique pilotée depuis la logique de flow (voir le plan :
|
||||
# l'utilisateur a explicitement rejeté ce premier design). Toutes ses
|
||||
# données (source Forge/personnalisée, animations) vivent dans UNE
|
||||
# clé JSON _personnage_data (voir screens/rendering/personnage_data.py
|
||||
# et screens/elements/set_personnage_data.py) — pas de contrôle
|
||||
# générique ici, "custom_panel" bascule le panneau de propriétés vers
|
||||
# un bloc entièrement sur-mesure (galerie Forge ou import de sprites,
|
||||
# voir templates/screen_edit.html et static/js/screen_edit/
|
||||
# personnage-panel.js), la taille/position restant les réglages
|
||||
# universels déjà là pour tout élément.
|
||||
"label": "Personnage", "icon": "🏃", "tag": "img", "has_content": False, "self_closing": True,
|
||||
"special_render": "personnage", "custom_panel": "personnage",
|
||||
"w": 20, "h": 30, "default_content": "",
|
||||
"controls": [],
|
||||
},
|
||||
"icone": {
|
||||
# Un SVG affiché en silhouette via "mask-image" (voir .icon-svg dans
|
||||
# style.css et render_icone.py) plutôt qu'une police d'icônes —
|
||||
|
||||
@@ -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;
|
||||
|
||||
+99
-46
@@ -40,6 +40,27 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Phase 8 — galerie visuelle de personnages Forge (VRAIES miniatures,
|
||||
pas un emoji/icône), réutilisée deux fois : "add" pour poser un
|
||||
nouveau personnage (panneau gauche), "swap" pour changer le
|
||||
personnage Forge d'un élément déjà posé (panneau droit, propriétés). -->
|
||||
{% macro render_personnage_gallery(mode, gallery_id, selected_forge_character=None) %}
|
||||
<div class="iconGallery" id="{{ gallery_id }}">
|
||||
{% for char_slug, character in sprite_library.items() %}
|
||||
<button type="button" class="iconGalleryTile {{ 'selected' if char_slug == selected_forge_character else '' }}"
|
||||
title="{{ character.label }}"
|
||||
onclick="{{ 'addPersonnageElement' if mode == 'add' else 'swapPersonnageCharacter' }}('{{ char_slug }}')">
|
||||
<img src="/static/characters/{{ char_slug }}/idle.png" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if mode == 'add' %}
|
||||
<button type="button" class="iconGalleryTile" title="Sprite personnalisé" onclick="addPersonnageElement('__custom__')">
|
||||
<span style="font-size:28px;">➕</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="builder3" id="builder3">
|
||||
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
|
||||
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) —
|
||||
@@ -102,7 +123,7 @@
|
||||
<summary>Ajouter un élément sur l'écran</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="widgetGrid">
|
||||
{% for wkey, w in widgets.items() if wkey != 'icone' %}
|
||||
{% for wkey, w in widgets.items() if wkey != 'icone' and wkey != 'personnage' %}
|
||||
<form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
|
||||
<input type="hidden" name="widget" value="{{ wkey }}">
|
||||
<button type="submit" class="widgetBtn" title="{{ w.label }}">
|
||||
@@ -136,6 +157,21 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Phase 8 — galerie de personnages Forge (widget "personnage" dédié,
|
||||
voir screens/widgets/registry.py) : cliquer une tuile pose un
|
||||
personnage DÉJÀ configuré (Kenney CC0), animé immédiatement dans
|
||||
le canevas (voir personnage-preview.js) — pas un élément générique
|
||||
à configurer ensuite dans la logique. "➕ Sprite personnalisé" pose
|
||||
un personnage vide dont les animations s'importent dans ses
|
||||
propriétés (panneau droit). -->
|
||||
<details class="propGroup">
|
||||
<summary>🎭 Personnages</summary>
|
||||
<div class="propGroupBody">
|
||||
{{ render_personnage_gallery('add', 'personnageGalleryAdd') }}
|
||||
<p class="help">Personnage prêt à l'emploi (Kenney, licence libre CC0) ou "➕" pour un sprite personnalisé — importe ensuite tes propres images d'animation dans ses propriétés.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Menu rétractable dédié aux éléments de jeu personnalisés (Personnage,
|
||||
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
|
||||
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
|
||||
@@ -505,36 +541,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 7 — personnage animé par sprites : joue une séquence de
|
||||
PNG (cycle en boucle type marche, ou une fois type saut) sur
|
||||
l'élément visé, depuis la bibliothèque Forge ou des sprites
|
||||
propres au créateur (même bouton d'upload que le son ci-dessus,
|
||||
mais ajoute une ligne plutôt que de remplacer le champ). -->
|
||||
<!-- Phase 8 — personnage animé par sprites : joue une des
|
||||
animations du PERSONNAGE CIBLÉ (miniatures réelles, jamais un
|
||||
menu déroulant ni une zone d'URLs en texte) — seuls les
|
||||
éléments widget "personnage" sont proposés ici, leurs
|
||||
animations viennent de leurs propres propriétés (galerie
|
||||
Forge ou sprites importés, voir personnage-panel.js). -->
|
||||
<div id="nf-action-sprite-fields" style="display:none;">
|
||||
<label>Élément à animer
|
||||
<select id="nf-action-sprite-target-element">
|
||||
{% for e2 in elements %}
|
||||
<label>Personnage à animer
|
||||
<select id="nf-action-sprite-target-element" onchange="onSpriteTargetElementChange()">
|
||||
<option value="">— Choisir —</option>
|
||||
{% for e2 in elements if e2.widget == 'personnage' %}
|
||||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label style="margin-top:6px; display:block;">Personnage Forge (optionnel)
|
||||
<select id="nf-action-sprite-library-character" onchange="onSpriteLibraryCharacterChange()">
|
||||
<option value="">— Choisir —</option>
|
||||
{% for slug, character in sprite_library.items() %}
|
||||
<option value="{{ slug }}">{{ character.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<select id="nf-action-sprite-library-animation" style="margin-top:6px; display:none;" onchange="applySpriteLibraryAnimation()"></select>
|
||||
<label style="margin-top:10px; display:block;">Images (une URL par ligne)
|
||||
<textarea id="nf-action-sprite-frames" rows="4" style="width:100%;" placeholder="https://.../frame1.png"></textarea>
|
||||
</label>
|
||||
<div style="margin-top:6px;">
|
||||
<label for="nf-action-sprite-upload" style="font-size:12px;">Ajouter un fichier :</label>
|
||||
<input type="file" id="nf-action-sprite-upload" accept="image/*" style="font-size:12px;">
|
||||
<span id="nf-action-sprite-upload-status" class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
|
||||
</div>
|
||||
{% if not elements|selectattr('widget', 'equalto', 'personnage')|list %}
|
||||
<p class="hint">Aucun personnage sur cet écran — ajoute-en un depuis la galerie "🎭 Personnages" du panneau de gauche.</p>
|
||||
{% endif %}
|
||||
<div id="nf-action-sprite-animation-grid" class="iconGallery" style="margin-top:6px;"></div>
|
||||
<input type="hidden" id="nf-action-sprite-animation-name" value="">
|
||||
<div class="fieldRow" style="margin-top:6px;">
|
||||
<label style="flex:1;">Images/seconde
|
||||
<input type="number" id="nf-action-sprite-fps" min="1" step="1" value="8">
|
||||
@@ -638,27 +664,15 @@
|
||||
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 7 — même champs que le nœud de flow "Jouer une animation
|
||||
de sprites" (voir plus haut #nf-action-sprite-fields), formulaire
|
||||
indépendant : bibliothèque Forge ou sprites propres au créateur. -->
|
||||
<!-- Phase 8 — même principe que le nœud de flow "Jouer une
|
||||
animation de sprites" (voir plus haut #nf-action-sprite-fields) :
|
||||
miniatures réelles des SEULES animations du personnage propriétaire
|
||||
de ce clip (clip.element_id, fixé à la création du clip — voir
|
||||
addAnimTrack()), jamais un menu déroulant ni une zone d'URLs. -->
|
||||
<div id="anim-clip-sprite-fields" style="display:none;">
|
||||
<label>Personnage Forge (optionnel)
|
||||
<select id="anim-clip-sprite-library-character" onchange="onAnimClipSpriteLibraryCharacterChange()">
|
||||
<option value="">— Choisir —</option>
|
||||
{% for slug, character in sprite_library.items() %}
|
||||
<option value="{{ slug }}">{{ character.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<select id="anim-clip-sprite-library-animation" style="margin-top:6px; display:none;" onchange="applyAnimClipSpriteLibraryAnimation()"></select>
|
||||
<label style="margin-top:10px; display:block;">Images (une URL par ligne)
|
||||
<textarea id="anim-clip-sprite-frames" rows="4" style="width:100%;" placeholder="https://.../frame1.png"></textarea>
|
||||
</label>
|
||||
<div style="margin-top:6px;">
|
||||
<label for="anim-clip-sprite-upload" style="font-size:12px;">Ajouter un fichier :</label>
|
||||
<input type="file" id="anim-clip-sprite-upload" accept="image/*" style="font-size:12px;">
|
||||
<span id="anim-clip-sprite-upload-status" class="uploadStatus" style="font-size:12px; color:var(--text-dim);"></span>
|
||||
</div>
|
||||
<p class="hint" id="anim-clip-sprite-no-animations" style="display:none;">Cet élément n'est pas un personnage (widget "Personnage"), ou n'a encore aucune animation.</p>
|
||||
<div id="anim-clip-sprite-animation-grid" class="iconGallery"></div>
|
||||
<input type="hidden" id="anim-clip-sprite-animation-name" value="">
|
||||
<label style="margin-top:6px; display:block;">Images/seconde
|
||||
<input type="number" id="anim-clip-sprite-fps" min="1" step="1" value="8">
|
||||
</label>
|
||||
@@ -883,6 +897,42 @@
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if el.widget == 'personnage' %}
|
||||
<!-- Phase 8 — panneau sur-mesure (custom_panel, voir registry.py) :
|
||||
pas de controls génériques, juste la source (Forge/personnalisée)
|
||||
et son contenu. Sauvegarde en AJAX (personnage-panel.js), jamais
|
||||
via #propsForm (générique, utilisé par tous les autres widgets). -->
|
||||
<details class="propGroup" open>
|
||||
<summary>🎭 Personnage</summary>
|
||||
<div class="propGroupBody" id="personnagePanelBody" data-element-id="{{ el.id }}" data-source="{{ personnage_data.source }}">
|
||||
{% if personnage_data.source == 'custom' %}
|
||||
<p class="help">Sprite personnalisé — importe les images de chaque animation ("idle" recommandée, affichée par défaut).</p>
|
||||
<div id="personnageAnimationsList">
|
||||
{% for anim_name, frames in (personnage_data.animations or {}).items() %}
|
||||
<div class="personnageAnimRow box" data-anim-name="{{ anim_name }}" style="margin-bottom:8px; padding:8px;">
|
||||
<div class="fieldRow" style="align-items:center; gap:6px;">
|
||||
<input type="text" class="input is-small personnageAnimName" value="{{ anim_name }}" 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;">
|
||||
{% for url in frames %}
|
||||
<img src="{{ url }}" alt="" class="personnageFrameThumb" style="width:32px; height:32px; object-fit:contain; background:var(--panel2); border-radius:4px;">
|
||||
{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="button is-small is-fullwidth" onclick="addPersonnageAnimation()">+ Ajouter une animation</button>
|
||||
{% else %}
|
||||
<p class="help">Personnage Forge — clique un autre pour en changer.</p>
|
||||
{{ render_personnage_gallery('swap', 'personnageGallerySwap', personnage_data.forge_character) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% set size_ctrl_keys = ['child-width', 'child-height'] %}
|
||||
{% set size_ctrls = {} %}
|
||||
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
|
||||
@@ -1218,6 +1268,7 @@
|
||||
var ELEMENT_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
|
||||
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
|
||||
var SPRITE_LIBRARY = {{ sprite_library_json|safe }};
|
||||
var ELEMENT_ANIMATIONS_MAP = {{ element_animations_map_json|safe }};
|
||||
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
|
||||
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
|
||||
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
|
||||
@@ -1227,9 +1278,11 @@
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-panel.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/flow-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/tabs-and-blocks.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/animation-timeline.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
|
||||
<script>
|
||||
// Onglet initial : ?tab=... (utilisé par les routes de CRUD des
|
||||
// événements/blocs, pour revenir directement sur le bon onglet après
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Widget "Personnage" (Phase 8) — refonte visuelle du système de sprites
|
||||
de la Phase 7, à la demande de l'utilisateur : un vrai objet posé sur la
|
||||
scène (galerie visuelle, pas un élément générique piloté depuis la
|
||||
logique de flow), affichant sa pose "idle" dès le rendu SERVEUR. Le
|
||||
moteur d'exécution (runSpriteAnimation, kind="sprite" de la Timeline)
|
||||
reste celui de la Phase 7, inchangé — seul l'auteuring dans l'éditeur est
|
||||
testé ici."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import screen_edit_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_personnage(client, slug, screen_id, forge_character=None):
|
||||
data = {"widget": "personnage"}
|
||||
if forge_character:
|
||||
data["forge_character"] = forge_character
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data=data, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_freshly_created_personnage_defaults_to_forge_character(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id)
|
||||
el = screens.get_element(game, el_id)
|
||||
data = screens.resolve_personnage_data(el)
|
||||
assert data["source"] == "forge"
|
||||
assert data["forge_character"] in screens.SPRITE_LIBRARY
|
||||
|
||||
|
||||
def test_personnage_renders_idle_frame_server_side_without_js(client, game):
|
||||
"""Confirme l'aperçu correct même sans JS (voir render_personnage.py) :
|
||||
l'élément fraîchement posé montre déjà une vraie image, jamais un <img
|
||||
src=""> cassé qu'il faudrait d'abord configurer."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="zombie")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert '/static/characters/zombie/idle.png' in html
|
||||
assert f'data-element-id="{el_id}"' in html
|
||||
assert 'data-personnage="1"' in html
|
||||
|
||||
|
||||
def test_add_personnage_with_specific_forge_character(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="zombie")
|
||||
el = screens.get_element(game, el_id)
|
||||
data = screens.resolve_personnage_data(el)
|
||||
assert data == {"source": "forge", "forge_character": "zombie"}
|
||||
|
||||
|
||||
def test_add_custom_sprite_personnage(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="__custom__")
|
||||
el = screens.get_element(game, el_id)
|
||||
data = screens.resolve_personnage_data(el)
|
||||
assert data == {"source": "custom", "animations": {}}
|
||||
|
||||
|
||||
def test_swap_forge_character_via_ajax_route(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="male-adventurer")
|
||||
resp = client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
data=json.dumps({"source": "forge", "forge_character": "zombie"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["animations"]["idle"] == ["/static/characters/zombie/idle.png"]
|
||||
el = screens.get_element(game, el_id)
|
||||
assert screens.resolve_personnage_data(el)["forge_character"] == "zombie"
|
||||
|
||||
|
||||
def test_import_custom_animations_via_ajax_route(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="__custom__")
|
||||
custom = {"source": "custom", "animations": {"idle": ["https://x/idle.png"], "marche": ["https://x/w0.png", "https://x/w1.png"]}}
|
||||
resp = client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
data=json.dumps(custom),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["animations"] == custom["animations"]
|
||||
el = screens.get_element(game, el_id)
|
||||
assert screens.resolve_personnage_animations(el) == custom["animations"]
|
||||
|
||||
|
||||
def test_resolve_personnage_idle_frame_falls_back_to_first_animation(client, game):
|
||||
"""Un sprite personnalisé sans animation nommée "idle" (juste "marche"
|
||||
par ex.) doit quand même montrer QUELQUE CHOSE plutôt qu'une image
|
||||
cassée — retombe sur la première animation disponible."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="__custom__")
|
||||
client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
data=json.dumps({"source": "custom", "animations": {"marche": ["https://x/w0.png"]}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
el = screens.get_element(game, el_id)
|
||||
assert screens.resolve_personnage_idle_frame(el) == "https://x/w0.png"
|
||||
|
||||
|
||||
def test_screen_edit_exposes_personnage_gallery_and_scripts(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "male-adventurer" in html
|
||||
assert "addPersonnageElement" in html or "personnageGallery" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "addPersonnageElement" in js
|
||||
@@ -111,5 +111,8 @@ def test_screen_edit_exposes_sprite_animation_editors(client, game):
|
||||
assert "anim-clip-sprite-fields" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "jouer_animation_sprite" in js
|
||||
assert "onSpriteLibraryCharacterChange" in js
|
||||
assert "onAnimClipSpriteLibraryCharacterChange" in js
|
||||
# Phase 8 — refonte visuelle : le sélecteur d'animation est un vrai
|
||||
# sélecteur à miniatures scopé au personnage ciblé, plus un menu
|
||||
# déroulant "Personnage Forge" global (voir test_personnage_widget.py).
|
||||
assert "onSpriteTargetElementChange" in js
|
||||
assert "populateAnimClipSpriteGrid" in js
|
||||
|
||||
Reference in New Issue
Block a user