Files
Forge-Engine/tests/test_sprite_animation.py
T
williamandClaude Sonnet 5 70c2b8df05
Build and deploy / test-python (push) Successful in 1m39s
Build and deploy / test-js (push) Successful in 7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Corrige : une animation déjà posée dans la logique ignorait le changement de personnage
Bug réel identifié grâce à la vidéo fournie + inspection directe de la
base de jeu de test : quand un nœud de flow "Jouer une animation"
(ou un clip de Timeline "sprite") était configuré pour un personnage,
ses frames étaient résolues et FIGÉES dans data_value/custom_keyframes
au moment de la configuration — changer ensuite le personnage Forge de
l'élément (galerie des propriétés) n'avait donc aucun effet sur les
animations déjà posées, qui continuaient à jouer indéfiniment les
frames de l'ANCIEN personnage.

Le nœud/clip ne stocke désormais que le NOM de l'animation
({"animation": "walk", "fps": 8, "loop": true}) — ses frames sont
résolues à l'EXÉCUTION, à partir du personnage ACTUELLEMENT assigné à
l'élément cible :
- screens/payload/full_game_payload.py expose un nouveau
  gameData.personnage_animations (élément → animations), reconstruit à
  chaque chargement de la page de jeu depuis _personnage_data — donc
  toujours à jour, y compris après un changement de personnage.
- static/js/play/actions.js (resolveSpriteFrames) et
  static/js/play/screens.js (applyAnimationClip) résolvent le nom
  d'animation en frames à ce moment précis, plutôt que d'utiliser des
  frames figées — repli sur l'ancien format {frames,...} pour les
  nœuds/clips déjà créés avant ce correctif.
- Éditeur (flow-editor.js/animation-timeline.js) : simplifié en
  conséquence — plus besoin de deviner rétroactivement quelle animation
  correspond à une liste de frames stockées (l'ancien hack de
  comparaison), le nom est maintenant stocké directement.

Nouveau test de régression (test_swapping_forge_character_updates_
already_configured_flow_action) qui reproduit exactement le scénario
filmé : configure l'action pour "male-adventurer", change le personnage
en "zombie", vérifie que gameData.personnage_animations reflète bien
zombie sans avoir à retoucher le nœud de flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 08:56:20 +02:00

156 lines
6.9 KiB
Python

"""Personnage animé par sprites (poses/images successives) — Phase 7 du
plan d'extension du moteur. Deux entrées équivalentes : une action de flow
"jouer_animation_sprite" (événementiel) et un clip de la Timeline
d'animation de kind "sprite" (déclaratif, voir tests/test_animations.py
pour le patron des clips). Comme le reste du graphe de logique côté
client, la lecture réelle de l'animation n'est pas testable sans navigateur
(voir play_js_bundle() — on vérifie que la logique existe dans le JS livré
et que les données persistent, pas qu'elles jouent une animation)."""
import json
import os
import re
from conftest import play_js_bundle, screen_edit_js_bundle
import screens
_STATIC_CHARACTERS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "characters"
)
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_element(client, slug, screen_id, widget="image"):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_sprite_animation_action_node_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
data_value = json.dumps({"frames": ["/static/characters/zombie/walk0.png", "/static/characters/zombie/walk1.png"], "fps": 8, "loop": True})
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
target_element_id=el_id, data_value=data_value,
)
node = screens.get_flow_node(game, node_id)
assert node["action_type"] == "jouer_animation_sprite"
assert node["target_element_id"] == el_id
assert json.loads(node["data_value"])["fps"] == 8
def test_orientation_action_node_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="modifier_element",
target_element_id=el_id, element_property="orientation", element_value="gauche",
)
node = screens.get_flow_node(game, node_id)
assert node["element_property"] == "orientation"
assert node["element_value"] == "gauche"
def test_sprite_timeline_clip_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
sprite_data = {"frames": ["/static/characters/male-adventurer/idle.png"], "fps": 8}
resp = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({
"element_id": el_id, "kind": "sprite", "custom_keyframes": sprite_data,
"start_time": "0", "iteration_count": "infinite",
}),
content_type="application/json",
)
assert resp.status_code == 200
clip = resp.get_json()
assert clip["kind"] == "sprite"
assert clip["iteration_count"] == "infinite"
assert json.loads(clip["custom_keyframes"])["frames"] == ["/static/characters/male-adventurer/idle.png"]
def test_sprite_library_paths_exist_on_disk(client, game):
for character in screens.SPRITE_LIBRARY.values():
for frames in character["animations"].values():
assert frames, "chaque animation doit avoir au moins une frame"
for url in frames:
assert url.startswith("/static/characters/")
relative = url[len("/static/characters/"):]
assert os.path.isfile(os.path.join(_STATIC_CHARACTERS_DIR, relative)), url
def test_play_page_exposes_sprite_animation_runtime(client, game):
js = play_js_bundle()
assert "runSpriteAnimation" in js
assert "'jouer_animation_sprite'" in js
assert "activeSpriteAnimations" in js
assert "stopAllSpriteAnimations" in js
assert "forgeFlipped" in js
# Phase 8 — résolution à l'exécution depuis le personnage actuellement
# assigné (voir résolution du bug ci-dessous), pas des frames figées.
assert "resolveSpriteFrames" in js
assert "personnage_animations" in js
def test_swapping_forge_character_updates_already_configured_flow_action(client, game):
"""Régression : un nœud "Jouer une animation" configuré pour un
personnage devait rester bloqué sur les frames de l'ANCIEN personnage
même après avoir changé le personnage Forge de l'élément dans
l'éditeur (signalé par l'utilisateur). data_value ne stocke plus que
le NOM de l'animation — resolveSpriteFrames() la résout à l'exécution
depuis gameData.personnage_animations, reconstruit à chaque
chargement de full_game_payload() à partir du personnage ACTUEL."""
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "personnage", "forge_character": "male-adventurer"},
follow_redirects=False,
)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
_add_flow_node(
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
target_element_id=el_id, data_value=json.dumps({"animation": "walk", "fps": 8, "loop": True}),
)
payload = screens.full_game_payload(game)
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/male-adventurer/")
client.post(
f"/game/{game}/elements/{el_id}/personnage",
data=json.dumps({"source": "forge", "forge_character": "zombie"}),
content_type="application/json",
)
payload = screens.full_game_payload(game)
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/zombie/")
def test_screen_edit_exposes_sprite_animation_editors(client, game):
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "nf-action-sprite-fields" in html
assert "nf-action-sprite-target-element" in html
assert "anim-clip-sprite-fields" in html
js = screen_edit_js_bundle()
assert "jouer_animation_sprite" 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