From 9e076f8cf9ad1c6abbefa47c0d7f6afba0aa05f2 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 20:26:39 +0200 Subject: [PATCH] Refonte : vrai widget "Personnage" visuel (remplace l'UI de la Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- routes/elements/__init__.py | 1 + routes/elements/element_add.py | 11 ++ .../elements/element_set_personnage_data.py | 21 +++ routes/screens/screen_edit.py | 14 ++ screens/__init__.py | 2 + screens/elements/set_personnage_data.py | 20 +++ screens/rendering/personnage_data.py | 39 +++++ screens/rendering/render_element_html.py | 3 + screens/rendering/render_personnage.py | 21 +++ screens/widgets/default_style_for_widget.py | 12 ++ screens/widgets/registry.py | 17 ++ static/js/screen_edit/animation-timeline.js | 85 ++++++---- static/js/screen_edit/flow-editor.js | 69 +++++---- static/js/screen_edit/panel-init.js | 49 ------ static/js/screen_edit/personnage-panel.js | 111 ++++++++++++++ static/js/screen_edit/personnage-preview.js | 55 +++++++ static/js/screen_edit/tree-panels.js | 15 ++ static/style.css | 5 + templates/screen_edit.html | 145 ++++++++++++------ tests/test_personnage_widget.py | 117 ++++++++++++++ tests/test_sprite_animation.py | 7 +- 21 files changed, 660 insertions(+), 159 deletions(-) create mode 100644 routes/elements/element_set_personnage_data.py create mode 100644 screens/elements/set_personnage_data.py create mode 100644 screens/rendering/personnage_data.py create mode 100644 screens/rendering/render_personnage.py create mode 100644 static/js/screen_edit/personnage-panel.js create mode 100644 static/js/screen_edit/personnage-preview.js create mode 100644 tests/test_personnage_widget.py diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py index ad59e67d..bbad48e1 100644 --- a/routes/elements/__init__.py +++ b/routes/elements/__init__.py @@ -10,4 +10,5 @@ from . import ( element_duplicate, element_rename, element_tab_add, + element_set_personnage_data, ) diff --git a/routes/elements/element_add.py b/routes/elements/element_add.py index 02bfab4c..12b7e017 100644 --- a/routes/elements/element_add.py +++ b/routes/elements/element_add.py @@ -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)) diff --git a/routes/elements/element_set_personnage_data.py b/routes/elements/element_set_personnage_data.py new file mode 100644 index 00000000..226670fc --- /dev/null +++ b/routes/elements/element_set_personnage_data.py @@ -0,0 +1,21 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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)}) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 3511a215..ed66b2ef 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -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), diff --git a/screens/__init__.py b/screens/__init__.py index 2e40a289..2f522f2e 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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 diff --git a/screens/elements/set_personnage_data.py b/screens/elements/set_personnage_data.py new file mode 100644 index 00000000..93d61e94 --- /dev/null +++ b/screens/elements/set_personnage_data.py @@ -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() diff --git a/screens/rendering/personnage_data.py b/screens/rendering/personnage_data.py new file mode 100644 index 00000000..e613c2b1 --- /dev/null +++ b/screens/rendering/personnage_data.py @@ -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 "" diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index 65a00e74..05449ac5 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -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) diff --git a/screens/rendering/render_personnage.py b/screens/rendering/render_personnage.py new file mode 100644 index 00000000..71e2c03c --- /dev/null +++ b/screens/rendering/render_personnage.py @@ -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 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"" diff --git a/screens/widgets/default_style_for_widget.py b/screens/widgets/default_style_for_widget.py index abe9b686..1be61822 100644 --- a/screens/widgets/default_style_for_widget.py +++ b/screens/widgets/default_style_for_widget.py @@ -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 diff --git a/screens/widgets/registry.py b/screens/widgets/registry.py index b1b05136..1513efd3 100644 --- a/screens/widgets/registry.py +++ b/screens/widgets/registry.py @@ -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 — diff --git a/static/js/screen_edit/animation-timeline.js b/static/js/screen_edit/animation-timeline.js index ad69c17e..2e482d6d 100644 --- a/static/js/screen_edit/animation-timeline.js +++ b/static/js/screen_edit/animation-timeline.js @@ -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 = ''; + 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, }; } diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index 0f88348f..f581ba35 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -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 = ''; + 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, }); diff --git a/static/js/screen_edit/panel-init.js b/static/js/screen_edit/panel-init.js index 0912b962..d560661f 100644 --- a/static/js/screen_edit/panel-init.js +++ b/static/js/screen_edit/panel-init.js @@ -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']); diff --git a/static/js/screen_edit/personnage-panel.js b/static/js/screen_edit/personnage-panel.js new file mode 100644 index 00000000..f255f9ff --- /dev/null +++ b/static/js/screen_edit/personnage-panel.js @@ -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//elements//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 = + '
' + + '' + + '' + + '
' + + '
' + + '' + + ''; + 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(); + }); + } +}); diff --git a/static/js/screen_edit/personnage-preview.js b/static/js/screen_edit/personnage-preview.js new file mode 100644 index 00000000..b60e5e05 --- /dev/null +++ b/static/js/screen_edit/personnage-preview.js @@ -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(); diff --git a/static/js/screen_edit/tree-panels.js b/static/js/screen_edit/tree-panels.js index bd3d72dd..4c9d224a 100644 --- a/static/js/screen_edit/tree-panels.js +++ b/static/js/screen_edit/tree-panels.js @@ -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 = + '' + + ''; + document.body.appendChild(form); + form.requestSubmit(); +} + function filterIconGallery(query) { var q = query.trim().toLowerCase(); document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) { diff --git a/static/style.css b/static/style.css index c267766e..4392469c 100644 --- a/static/style.css +++ b/static/style.css @@ -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 (, 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; diff --git a/templates/screen_edit.html b/templates/screen_edit.html index ff67e28c..d41277f4 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -40,6 +40,27 @@ {% endmacro %} + +{% macro render_personnage_gallery(mode, gallery_id, selected_forge_character=None) %} + +{% endmacro %} +
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %} +
+ 🎭 Personnages +
+ {{ render_personnage_gallery('add', 'personnageGalleryAdd') }} +

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.

+
+
+ +