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
+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
js = screen_edit_js_bundle()
assert "jouer_animation_sprite" in js
assert "onSpriteLibraryCharacterChange" in js
assert "onAnimClipSpriteLibraryCharacterChange" in js
# Phase 8 — refonte visuelle : le sélecteur d'animation est un vrai
# sélecteur à miniatures scopé au personnage ciblé, plus un menu
# déroulant "Personnage Forge" global (voir test_personnage_widget.py).
assert "onSpriteTargetElementChange" in js
assert "populateAnimClipSpriteGrid" in js