Deuxième et dernier commit de la Phase A du plan "Fondations d'une
plateforme multi-éditeurs" (le premier, efa7d1d, posait le schéma et le
CRUD des objets de scène). Ce commit branche l'éditeur et le mode
jouable sur ces fondations, en réutilisant TEL QUEL tout ce qui est déjà
générique côté moteur de logique.
Éditeur (routes/scenes/, templates/scene_edit.html,
static/js/scenes/scene-editor.js) :
- routes/screens/screen_edit.py délègue à render_scene_edit() dès que
game_type == "jeu_2d" — même endpoint Flask "screen_edit" pour les
deux éditeurs, aucune route dupliquée.
- Nouveau template scene_edit.html : canevas à taille FIXE en pixels
(scene_width/scene_height) avec glisser-déposer/redimensionnement en
pixels (scene-editor.js, mirror pixel de tree-panels.js), galerie
personnages/décors. Les onglets "Blocs de logique"/"Timeline
d'animation"/"Événements" réutilisent tels quels flow-editor.js,
tabs-and-blocks.js et animation-timeline.js.
- flow-editor.js : nouveau flag FLOW_TARGETS_OBJECTS (false par défaut,
true dans scene_edit.html) qui fait écrire submitNodeForm() vers
trigger_object_id/target_object_id au lieu de trigger_element_id/
target_element_id, plus une branche "Modifier un objet de scène"
(position px, visibilité, orientation) et des gardes null partout
(le DOM de scene_edit.html n'a pas tous les champs de l'éditeur
document).
- blocks_view.py étend l'ensemble d'ids d'éléments concernés par un
bloc pour inclure aussi trigger_object_id/target_object_id.
Runtime jouable (templates/play.html, static/js/play/actions.js,
screens/payload/full_game_payload.py) :
- full_game_payload() construit les écrans d'un jeu jeu_2d à partir de
_scene_objects (render_scene_object) au lieu de _screen_elements, et
récupère les animations de personnage sur les objets de scène.
- play.html : #playFrame occupe tout le viewport pour une scène jeu_2d
(pas de ratio fluide) ; la scène se centre via .playScreen.playScene,
qui réutilise tel quel showScreen() (déjà indexé par id d'écran, pas
par forme DOM) — aucun fichier "scenes.js" séparé n'a été nécessaire.
- actions.js : jouer_animation_sprite retombe sur target_object_id si
target_element_id est absent ; nouvelle action modifier_objet_scene
avec applyObjectProperty() (positions en px, contrairement aux % de
applyElementProperty()).
Un bug de gabarit a été découvert et corrigé pendant la vérification :
le commentaire CSS de play.html contenait littéralement "{% for %}"
comme texte français, que Jinja interprétait comme une vraie balise et
faisait planter le rendu — reformulé. render_scene_object() n'était en
outre jamais appelé par la vue de l'éditeur (les objets de scène
s'affichaient sans image) — scene_edit_view.py attache maintenant
rendered_html à chaque objet avant de les passer au template.
Nouveaux tests (tests/test_scene_edit_view.py, 10 cas) : dispatch
document vs jeu_2d, rendu d'un personnage sélectionné, CRUD géométrie/
suppression d'objet, nœuds de flow ciblant un objet de scène (action et
condition de collision), payload et page /play pour un jeu jeu_2d,
présence des nouveaux helpers JS. Suite complète : 309 tests passent
(299 existants + 10 nouveaux, aucune régression). node --check et
node --test (13 tests JS) passent également.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
6.1 KiB
Python
134 lines
6.1 KiB
Python
"""Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) —
|
|
appelée par routes/screens/screen_edit.py selon game_type, RÉUTILISE tel
|
|
quel le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/
|
|
animation-timeline.js) — voir le plan "Fondations d'une plateforme
|
|
multi-éditeurs"."""
|
|
import json
|
|
import re
|
|
|
|
import db
|
|
import screens
|
|
from conftest import screen_edit_js_bundle
|
|
|
|
|
|
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"):
|
|
resp = client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False)
|
|
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
|
screen_id = screens.create_screen(slug, "Scène 1")
|
|
return slug, screen_id
|
|
|
|
|
|
def test_document_game_still_uses_generic_editor(client, game):
|
|
"""Non-régression — critère de succès n°1 du plan : un jeu "document"
|
|
existant continue de fonctionner à l'identique."""
|
|
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
|
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
|
assert "ÉLÉMENTS DE CET ÉCRAN" in html.upper() or "Éléments de cet écran" in html
|
|
assert "scene-editor.js" not in html
|
|
|
|
|
|
def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
|
assert "Objets de cette scène" in html
|
|
assert "scene-editor.js" in html
|
|
assert "🎭 Personnages" in html
|
|
|
|
|
|
def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
resp = client.post(
|
|
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
|
data={"kind": "personnage", "forge_character": "zombie"},
|
|
follow_redirects=False,
|
|
)
|
|
assert resp.status_code == 302
|
|
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
|
|
assert "/static/characters/zombie/idle.png" in html
|
|
assert f'data-object-id="{obj_id}"' in html
|
|
assert "Personnage Forge" in html
|
|
|
|
|
|
def test_scene_object_geometry_route_persists_pixels(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
obj_id = screens.add_scene_object(slug, screen_id)
|
|
resp = client.post(
|
|
f"/game/{slug}/scene-objects/{obj_id}/geometry",
|
|
data=json.dumps({"x": 111, "y": 222, "width": 48, "height": 96}),
|
|
content_type="application/json",
|
|
)
|
|
assert resp.status_code == 200
|
|
obj = screens.get_scene_object(slug, obj_id)
|
|
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (111, 222, 48, 96)
|
|
|
|
|
|
def test_scene_object_delete_route(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
obj_id = screens.add_scene_object(slug, screen_id)
|
|
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert screens.get_scene_object(slug, obj_id) is None
|
|
|
|
|
|
def test_flow_node_targets_scene_object(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
|
|
resp = client.post(
|
|
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
|
data=json.dumps({
|
|
"node_type": "action", "action_type": "jouer_animation_sprite",
|
|
"target_object_id": obj_id, "data_value": json.dumps({"animation": "walk", "fps": 8, "loop": True}),
|
|
}),
|
|
content_type="application/json",
|
|
)
|
|
assert resp.status_code == 200
|
|
node_id = resp.get_json()["id"]
|
|
node = screens.get_flow_node(slug, node_id)
|
|
assert node["target_object_id"] == obj_id
|
|
assert node["target_element_id"] is None
|
|
|
|
|
|
def test_flow_node_collision_condition_targets_scene_objects(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
obj_a = screens.add_scene_object(slug, screen_id)
|
|
obj_b = screens.add_scene_object(slug, screen_id, kind="decor")
|
|
resp = client.post(
|
|
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
|
data=json.dumps({
|
|
"node_type": "condition", "cond_source": "collision",
|
|
"cond_element_a": obj_a, "cond_element_b": obj_b,
|
|
}),
|
|
content_type="application/json",
|
|
)
|
|
assert resp.status_code == 200
|
|
node = screens.get_flow_node(slug, resp.get_json()["id"])
|
|
assert node["cond_element_a"] == obj_a
|
|
assert node["cond_element_b"] == obj_b
|
|
|
|
|
|
def test_play_payload_renders_scene_objects_for_jeu2d_game(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
screens.add_scene_object(slug, screen_id, forge_character="robot")
|
|
payload = screens.full_game_payload(slug)
|
|
scene_payload = next(s for s in payload["screens"] if s["id"] == screen_id)
|
|
assert len(scene_payload["elements"]) == 1
|
|
assert "/static/characters/robot/idle.png" in scene_payload["elements"][0]["rendered_html"]
|
|
assert any(payload["personnage_animations"].values())
|
|
|
|
|
|
def test_play_html_renders_fixed_size_scene(client, tmp_game_slug_cleanup):
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
screens.add_scene_object(slug, screen_id, forge_character="zombie")
|
|
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
|
|
assert "playScene" in html
|
|
assert "/static/characters/zombie/idle.png" in html
|
|
assert f"width:{db.game_meta(slug)['name'] and 960}px" in html or "width:960px" in html
|
|
|
|
|
|
def test_screen_edit_js_bundle_contains_scene_flow_helpers():
|
|
js = screen_edit_js_bundle()
|
|
assert "FLOW_TARGETS_OBJECTS" in js
|
|
assert "toggleFlowObjectPropertyValue" in js
|