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
+1
View File
@@ -10,4 +10,5 @@ from . import (
element_duplicate, element_duplicate,
element_rename, element_rename,
element_tab_add, element_tab_add,
element_set_personnage_data,
) )
+11
View File
@@ -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 # défaut à la création plutôt que de repartir d'une icône neutre que
# l'utilisateur devrait ensuite changer manuellement. # l'utilisateur devrait ensuite changer manuellement.
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug}) 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)) 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)})
+14
View File
@@ -19,6 +19,7 @@ def screen_edit(slug, screen_id):
element_types = screens.list_element_types(slug) element_types = screens.list_element_types(slug)
selected_id = request.args.get("selected", type=int) selected_id = request.args.get("selected", type=int)
selected_controls = None selected_controls = None
personnage_data = None
if selected_id: if selected_id:
selected_el = next((e for e in elements if e["id"] == selected_id), None) selected_el = next((e for e in elements if e["id"] == selected_id), None)
if selected_el: if selected_el:
@@ -28,6 +29,11 @@ def screen_edit(slug, screen_id):
if owning_type: if owning_type:
template_definition_id = owning_type.get("bound_definition_id") template_definition_id = owning_type.get("bound_definition_id")
selected_controls = screens.controls_with_values(slug, selected_el, template_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 = {} children_of = {}
for el in elements: for el in elements:
if el.get("parent_id"): if el.get("parent_id"):
@@ -51,6 +57,12 @@ def screen_edit(slug, screen_id):
flow_edges = screens.list_flow_edges(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} 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} 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) animation_clips = screens.list_animation_clips(slug, screen_id)
global_variables = db.list_global_variables(slug) global_variables = db.list_global_variables(slug)
custom_events = db.list_custom_events(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, element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
sprite_library=screens.SPRITE_LIBRARY, sprite_library=screens.SPRITE_LIBRARY,
sprite_library_json=json.dumps(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_operations=screens.DATA_OPERATIONS,
data_operation_labels=screens.DATA_OPERATION_LABELS, data_operation_labels=screens.DATA_OPERATION_LABELS,
definitions=definitions, definitions_json=json.dumps(definitions_data), definitions=definitions, definitions_json=json.dumps(definitions_data),
+2
View File
@@ -63,6 +63,7 @@ from .flow.constants import (
from .rendering.render_element_html import render_element_html from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for 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.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens 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.flow_nodes_referencing_element import flow_nodes_referencing_element
from .elements.duplicate_element import duplicate_element from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_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 .elements.element_label import element_label
from .element_types.list_element_types import list_element_types from .element_types.list_element_types import list_element_types
+20
View File
@@ -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()
+39
View File
@@ -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 ""
+3
View File
@@ -15,6 +15,7 @@ from .render_jauge import _render_jauge
from .render_overlay import _render_overlay from .render_overlay import _render_overlay
from .render_onglets import _render_onglets from .render_onglets import _render_onglets
from .render_icone import _render_icone from .render_icone import _render_icone
from .render_personnage import _render_personnage
from .resolve_bound_row import _resolve_bound_row_ctx from .resolve_bound_row import _resolve_bound_row_ctx
from .visibility_condition import is_element_visible from .visibility_condition import is_element_visible
from ..element_types.load_template_tree import _load_template_tree 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)) return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
if special == "icone": if special == "icone":
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction)) 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") tag = meta.get("tag")
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3) if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
+21
View File
@@ -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 from .widget_meta import widget_meta
# Réglages "par côté" (padding/margin/bordure individuels) : contrairement # 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 # Visibilité → Rendre visible" pour l'ouvrir — cohérent avec
# "fermeture manuelle uniquement". # "fermeture manuelle uniquement".
style["display"] = "none" 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 return attributes, style
+17
View File
@@ -315,6 +315,23 @@ WIDGETS = {
"w": 60, "h": 40, "default_content": "", "w": 60, "h": 40, "default_content": "",
"controls": BULMA_TABS_CONTROLS, "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": { "icone": {
# Un SVG affiché en silhouette via "mask-image" (voir .icon-svg dans # 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 — # style.css et render_icone.py) plutôt qu'une police d'icônes —
+53 -32
View File
@@ -19,6 +19,7 @@ function animUrl(path) { return "/game/" + GAME_SLUG + path; }
function animClipLabel(clip) { function animClipLabel(clip) {
if (clip.kind === 'custom') return '🎨 Personnalisée'; if (clip.kind === 'custom') return '🎨 Personnalisée';
if (clip.kind === 'sprite') return '🏃 Sprite';
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name); 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-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-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
document.getElementById('anim-clip-sprite-fields').style.display = kind === 'sprite' ? '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()/ // Phase 8 — mirror de onSpriteTargetElementChange() (flow-editor.js), mais
// applySpriteLibraryAnimation() dans flow-editor.js, pour le formulaire de // scopé au personnage PROPRIÉTAIRE de ce clip (clip.element_id, fixé à la
// clip de Timeline (DOM distinct, pas d'état partagé entre les deux formulaires). // création — voir addAnimTrack()) : pas de sélecteur d'élément séparé ici,
function onAnimClipSpriteLibraryCharacterChange() { // contrairement au nœud de flow (une action peut viser n'importe quel
var charSel = document.getElementById('anim-clip-sprite-library-character'); // personnage, un clip appartient à un seul élément dès sa création).
var animSel = document.getElementById('anim-clip-sprite-library-animation'); function populateAnimClipSpriteGrid(preselectedName) {
var slug = charSel.value; var grid = document.getElementById('anim-clip-sprite-animation-grid');
animSel.innerHTML = ''; var nameField = document.getElementById('anim-clip-sprite-animation-name');
if (!slug || !SPRITE_LIBRARY[slug]) { animSel.style.display = 'none'; return; } var noAnimHint = document.getElementById('anim-clip-sprite-no-animations');
Object.keys(SPRITE_LIBRARY[slug].animations).forEach(function(name) { grid.innerHTML = '';
var opt = document.createElement('option'); var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
opt.value = name; opt.textContent = name; var animations = clip ? (ELEMENT_ANIMATIONS_MAP[clip.element_id] || {}) : {};
animSel.appendChild(opt); 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'; if (preselectedName && animations[preselectedName]) nameField.value = preselectedName;
applyAnimClipSpriteLibraryAnimation();
}
function applyAnimClipSpriteLibraryAnimation() {
var slug = document.getElementById('anim-clip-sprite-library-character').value;
var name = document.getElementById('anim-clip-sprite-library-animation').value;
if (!slug || !name || !SPRITE_LIBRARY[slug]) return;
document.getElementById('anim-clip-sprite-frames').value = SPRITE_LIBRARY[slug].animations[name].join('\n');
} }
function addAnimKeyframeRow(percent, cssText) { 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(' '); var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
addAnimKeyframeRow(k.percent, cssText); addAnimKeyframeRow(k.percent, cssText);
}); });
// Phase 7 — clip.custom_keyframes stocke {frames, fps} en JSON pour un // 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). // 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 = {}; var spriteData = {};
if (clip.kind === 'sprite' && clip.custom_keyframes) { if (clip.kind === 'sprite' && clip.custom_keyframes) {
try { spriteData = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ } 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; document.getElementById('anim-clip-sprite-fps').value = spriteData.fps || 8;
toggleAnimClipKind(); 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) { document.querySelectorAll('.animClip').forEach(function(b) {
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id); 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 === 'custom') fields.custom_keyframes = collectAnimKeyframes();
if (kind === 'sprite') { if (kind === 'sprite') {
// Phase 7 — {frames, fps} en JSON dans custom_keyframes (colonne // Phase 8 — l'animation choisie (miniature cliquée, voir
// générique déjà réutilisée par le kind "custom" ci-dessus pour ses // populateAnimClipSpriteGrid()) est résolue en liste de frames ICI ;
// images-clés) ; la boucle est déjà portée par iteration_count // {frames, fps} stocké dans custom_keyframes reste inchangé depuis la
// (1/2/3/infinite, ci-dessus) — aucun champ dédié nécessaire. // Phase 7 (moteur d'exécution intact, seule l'UI a changé) — la boucle
var frames = document.getElementById('anim-clip-sprite-frames').value // reste portée par iteration_count (1/2/3/infinite, ci-dessus).
.split('\n').map(function(s) { return s.trim(); }).filter(Boolean); 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 = { fields.custom_keyframes = {
frames: frames, frames: ownerAnimations[animName] || [],
fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8, 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'; var isOnglet = typeSel.value === 'activer_onglet';
soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none'; soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
spriteFields.style.display = typeSel.value === 'jouer_animation_sprite' ? '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'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? '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'; valueInp.placeholder = opSel.value === 'alea' ? 'min,max (ex. 1,6)' : 'Valeur / montant';
} }
// Phase 7 — bibliothèque de sprites Forge (SPRITE_LIBRARY, injecté par // Phase 8 — sélecteur d'animation à miniatures RÉELLES, scopé au
// screen_edit.html) : choisir un personnage puis une de ses animations // personnage réellement ciblé (ELEMENT_ANIMATIONS_MAP, injecté par
// pré-remplit la zone de frames — un créateur qui préfère ses propres // screen_edit.html à partir des propriétés de CET élément précis — voir
// sprites laisse simplement ce sélecteur sur "— Choisir —" et remplit/ // resolve_personnage_animations() côté serveur) — jamais un menu déroulant
// uploade directement dans la zone de frames. // ni un catalogue global. Mirror de onFlowDefinitionChange() (précédent le
function onSpriteLibraryCharacterChange() { // plus proche pour "choisir A repeuple B").
var charSel = document.getElementById('nf-action-sprite-library-character'); function onSpriteTargetElementChange() {
var animSel = document.getElementById('nf-action-sprite-library-animation'); var elSel = document.getElementById('nf-action-sprite-target-element');
var slug = charSel.value; var grid = document.getElementById('nf-action-sprite-animation-grid');
animSel.innerHTML = ''; var nameField = document.getElementById('nf-action-sprite-animation-name');
if (!slug || !SPRITE_LIBRARY[slug]) { animSel.style.display = 'none'; return; } grid.innerHTML = '';
Object.keys(SPRITE_LIBRARY[slug].animations).forEach(function(name) { nameField.value = '';
var opt = document.createElement('option'); var elementId = elSel.value;
opt.value = name; opt.textContent = name; var animations = elementId ? (ELEMENT_ANIMATIONS_MAP[elementId] || {}) : {};
animSel.appendChild(opt); 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() { function toggleFlowElementPropertyValue() {
@@ -740,13 +745,17 @@ function submitNodeForm() {
// colonne dédiée. // colonne dédiée.
payload.data_value = document.getElementById('nf-action-sound-url').value; payload.data_value = document.getElementById('nf-action-sound-url').value;
} else if (actionType === 'jouer_animation_sprite') { } else if (actionType === 'jouer_animation_sprite') {
// Phase 7 — réutilise target_element_id (déjà whitelisté) + // Phase 8 — l'animation choisie (miniature cliquée, voir
// data_value en JSON {frames, fps, loop}, même patron que jouer_son. // onSpriteTargetElementChange()) est résolue en liste de frames ICI,
payload.target_element_id = parseInt(document.getElementById('nf-action-sprite-target-element').value, 10); // à l'enregistrement : le nœud stocké reste exactement {frames, fps,
var frames = document.getElementById('nf-action-sprite-frames').value // loop} (Phase 7, moteur d'exécution inchangé — voir runSpriteAnimation()
.split('\n').map(function(s) { return s.trim(); }).filter(Boolean); // 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({ payload.data_value = JSON.stringify({
frames: frames, frames: animations[animName] || [],
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8, fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
loop: document.getElementById('nf-action-sprite-loop').checked, 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(); bindPropsAutosave();
bindJaugeDefinitionSelect(); bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']); 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(); 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) { function filterIconGallery(query) {
var q = query.trim().toLowerCase(); var q = query.trim().toLowerCase();
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) { 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:hover{ border-color:var(--accent); color:var(--accent); }
.iconGalleryTile.dragging{ opacity:0.4; } .iconGalleryTile.dragging{ opacity:0.4; }
.iconGalleryTile.iconHidden{ display:none; } .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; } .widgetBtnForm{ margin:0; }
.widgetBtn{ .widgetBtn{
width:100%; display:flex; flex-direction:column; align-items:center; gap:4px; width:100%; display:flex; flex-direction:column; align-items:center; gap:4px;
+99 -46
View File
@@ -40,6 +40,27 @@
</div> </div>
{% endmacro %} {% 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"> <div class="builder3" id="builder3">
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %} {% 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) — <!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) —
@@ -102,7 +123,7 @@
<summary>Ajouter un élément sur l'écran</summary> <summary>Ajouter un élément sur l'écran</summary>
<div class="propGroupBody"> <div class="propGroupBody">
<div class="widgetGrid"> <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"> <form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
<input type="hidden" name="widget" value="{{ wkey }}"> <input type="hidden" name="widget" value="{{ wkey }}">
<button type="submit" class="widgetBtn" title="{{ w.label }}"> <button type="submit" class="widgetBtn" title="{{ w.label }}">
@@ -136,6 +157,21 @@
</div> </div>
</details> </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, <!-- 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, 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 plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
@@ -505,36 +541,26 @@
</div> </div>
</div> </div>
<!-- Phase 7 — personnage animé par sprites : joue une séquence de <!-- Phase 8 — personnage animé par sprites : joue une des
PNG (cycle en boucle type marche, ou une fois type saut) sur animations du PERSONNAGE CIBLÉ (miniatures réelles, jamais un
l'élément visé, depuis la bibliothèque Forge ou des sprites menu déroulant ni une zone d'URLs en texte) — seuls les
propres au créateur (même bouton d'upload que le son ci-dessus, éléments widget "personnage" sont proposés ici, leurs
mais ajoute une ligne plutôt que de remplacer le champ). --> 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;"> <div id="nf-action-sprite-fields" style="display:none;">
<label>Élément à animer <label>Personnage à animer
<select id="nf-action-sprite-target-element"> <select id="nf-action-sprite-target-element" onchange="onSpriteTargetElementChange()">
{% for e2 in elements %} <option value="">— Choisir —</option>
{% for e2 in elements if e2.widget == 'personnage' %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option> <option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label style="margin-top:6px; display:block;">Personnage Forge (optionnel) {% if not elements|selectattr('widget', 'equalto', 'personnage')|list %}
<select id="nf-action-sprite-library-character" onchange="onSpriteLibraryCharacterChange()"> <p class="hint">Aucun personnage sur cet écran — ajoute-en un depuis la galerie "🎭 Personnages" du panneau de gauche.</p>
<option value="">— Choisir —</option> {% endif %}
{% for slug, character in sprite_library.items() %} <div id="nf-action-sprite-animation-grid" class="iconGallery" style="margin-top:6px;"></div>
<option value="{{ slug }}">{{ character.label }}</option> <input type="hidden" id="nf-action-sprite-animation-name" value="">
{% 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>
<div class="fieldRow" style="margin-top:6px;"> <div class="fieldRow" style="margin-top:6px;">
<label style="flex:1;">Images/seconde <label style="flex:1;">Images/seconde
<input type="number" id="nf-action-sprite-fps" min="1" step="1" value="8"> <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> <button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
</div> </div>
<!-- Phase 7 — même champs que le nœud de flow "Jouer une animation <!-- Phase 8 — même principe que le nœud de flow "Jouer une
de sprites" (voir plus haut #nf-action-sprite-fields), formulaire animation de sprites" (voir plus haut #nf-action-sprite-fields) :
indépendant : bibliothèque Forge ou sprites propres au créateur. --> 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;"> <div id="anim-clip-sprite-fields" style="display:none;">
<label>Personnage Forge (optionnel) <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>
<select id="anim-clip-sprite-library-character" onchange="onAnimClipSpriteLibraryCharacterChange()"> <div id="anim-clip-sprite-animation-grid" class="iconGallery"></div>
<option value="">— Choisir —</option> <input type="hidden" id="anim-clip-sprite-animation-name" value="">
{% 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>
<label style="margin-top:6px; display:block;">Images/seconde <label style="margin-top:6px; display:block;">Images/seconde
<input type="number" id="anim-clip-sprite-fps" min="1" step="1" value="8"> <input type="number" id="anim-clip-sprite-fps" min="1" step="1" value="8">
</label> </label>
@@ -883,6 +897,42 @@
</details> </details>
{% endif %} {% 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_ctrl_keys = ['child-width', 'child-height'] %}
{% set size_ctrls = {} %} {% set size_ctrls = {} %}
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %} {% 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_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }}; var ELEMENT_ORIENTATION_LABELS_MAP = {{ element_orientation_labels_json|safe }};
var SPRITE_LIBRARY = {{ sprite_library_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 DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }}; var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }}; var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
@@ -1227,9 +1278,11 @@
</script> </script>
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></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/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/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/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/animation-timeline.js') }}"></script>
<script src="{{ url_for('static', filename='js/screen_edit/personnage-preview.js') }}"></script>
<script> <script>
// Onglet initial : ?tab=... (utilisé par les routes de CRUD des // Onglet initial : ?tab=... (utilisé par les routes de CRUD des
// événements/blocs, pour revenir directement sur le bon onglet après // événements/blocs, pour revenir directement sur le bon onglet après
+117
View File
@@ -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
+5 -2
View File
@@ -111,5 +111,8 @@ def test_screen_edit_exposes_sprite_animation_editors(client, game):
assert "anim-clip-sprite-fields" in html assert "anim-clip-sprite-fields" in html
js = screen_edit_js_bundle() js = screen_edit_js_bundle()
assert "jouer_animation_sprite" in js assert "jouer_animation_sprite" in js
assert "onSpriteLibraryCharacterChange" in js # Phase 8 — refonte visuelle : le sélecteur d'animation est un vrai
assert "onAnimClipSpriteLibraryCharacterChange" in js # 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