"""Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) — appelée par routes/screens/screen_edit.py, l'unique moteur d'écran (jeu_2d).""" import json import re import db import screens def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"): resp = client.post("/games/new", data={"name": name}, follow_redirects=False) slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") return slug, screen_id 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_held_key_trigger_persists_and_is_in_scene_palette(client, tmp_game_slug_cleanup): """Mouvement continu (touche maintenue) — répond au manque signalé par l'utilisateur : "clavier" (keydown) ne se déclenche qu'une fois par appui, insuffisant pour faire avancer un personnage en continu tant qu'une touche reste enfoncée. Voir runScreenHeldKeyTriggers() (static/js/play/triggers.js) et TRIGGER_EVENTS (screens/flow/constants.py).""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) assert "touche_maintenue" in dict(screens.TRIGGER_EVENTS_2D) assert "touche_relachee" in dict(screens.TRIGGER_EVENTS_2D) resp = client.post( f"/game/{slug}/screens/{screen_id}/flow/nodes/add", data=json.dumps({"node_type": "trigger", "trigger_event": "touche_maintenue", "trigger_key": "ArrowRight"}), content_type="application/json", ) assert resp.status_code == 200 node = screens.get_flow_node(slug, resp.get_json()["id"]) assert node["trigger_event"] == "touche_maintenue" assert node["trigger_key"] == "ArrowRight" 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_render_scene_object_carries_data_anim_target(client, tmp_game_slug_cleanup): """Bug remonté par l'utilisateur : "les animations ne se jouent pas dans l'aperçu" — applyAnimationClip() (static/js/play/screens.js) et animation-timeline.js sélectionnent TOUJOURS leur cible via [data-anim-target=...], jamais data-element-id/data-object-id. Sans cet attribut sur l'objet de scène, AUCUN clip de Timeline (sprite, Animate.css ou personnalisé) ne trouvait jamais sa cible.""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_id = screens.add_scene_object(slug, screen_id) html = screens.render_scene_object(screens.get_scene_object(slug, obj_id)) assert f'data-anim-target="{obj_id}"' in html def test_animation_clip_add_route_accepts_a_scene_object(client, tmp_game_slug_cleanup): """Bug remonté par l'utilisateur : _animation_clips.element_id portait une VRAIE contrainte FK vers _screen_elements — poser un clip de Timeline sur un objet de scène (_scene_objects, un id space distinct) faisait échouer l'insertion avec "FOREIGN KEY constraint failed".""" 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}/screens/{screen_id}/animations/clips/add", data=json.dumps({"element_id": obj_id, "kind": "animate_css", "animate_name": "fadeIn"}), content_type="application/json", ) assert resp.status_code == 200 clip = resp.get_json() assert clip["element_id"] == obj_id def test_deleting_scene_object_cleans_up_its_animation_clips(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) screens.add_animation_clip(slug, screen_id, obj_id, kind="animate_css", animate_name="fadeIn") screens.delete_scene_object(slug, obj_id) assert screens.list_animation_clips(slug, screen_id) == [] def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(client, tmp_game_slug_cleanup): """Pas de "fond" plus grand que la scène -> le monde affiché reste exactement la scène nominale, et le repère "🎥 Champ de la caméra" (voir static/style.css .sceneCameraFrame) ne s'affiche pas — inutile tant que rien ne dépasse.""" 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 'id="canvas" style="width:960px; height:540px;' in html assert "sceneCameraFrame" not in html # "Caméra (px)" : TOUJOURS modifiable, même sans fond qui dépasse # (demande explicite : "l'écran de l'éditeur a une taille fixe, la # caméra a la même taille, c'est tout" — un réglage direct, pas # seulement suggéré automatiquement). assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="960"' in html assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="540"' in html def test_scene_edit_view_expands_world_for_an_oversized_fond(client, tmp_game_slug_cleanup): """Bug corrigé (glisser-déposer figé à (0,0) pour un objet plus grand que la scène, voir static/js/scenes/scene-editor.js) : le canevas de l'éditeur doit maintenant afficher tout le "fond", pas seulement la scène nominale (960×540) qu'il dépasse — sinon la partie qui dépasse resterait invisible/injoignable à la souris malgré le fix du glisser.""" slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) fond_id = screens.add_scene_object(slug, screen_id, kind="fond") screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080) html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert 'id="canvas" style="width:1920px; height:1080px;' in html # Le repère de la caméra marque toujours les bornes de la scène NOMINALE (960×540), # même si le canevas affiché est désormais bien plus grand. assert 'class="sceneCameraFrame" style="width:960px; height:540px;' in html def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) fond_id = screens.add_scene_object(slug, screen_id, kind="fond") screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080) html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert "var SCENE_WIDTH = 1920;" in html assert "var SCENE_HEIGHT = 1080;" in html def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup): """"Caméra (px)" (voir templates/scene_edit.html) : jusqu'ici scene_width/scene_height n'étaient fixées qu'à la création de l'écran, jamais modifiables ensuite.""" slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_size")) screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") screens.set_scene_size(slug, screen_id, 1920, 1080) screen = screens.get_screen(slug, screen_id) assert screen["scene_width"] == 1920 assert screen["scene_height"] == 1080 def test_screen_set_scene_size_route_persists_and_reflects_in_editor(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-size", data=json.dumps({"width": 1920, "height": 1080}), content_type="application/json", ) assert resp.get_json() == {"ok": True} html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert 'id="canvas" style="width:1920px; height:1080px;' in html # La scène nominale == le monde désormais -> plus de repère caméra. assert "sceneCameraFrame" not in html # Les champs "Caméra (px)" restent TOUJOURS visibles (pas conditionnels), # pré-remplis avec la nouvelle taille. assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="1920"' in html assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="1080"' in html def test_screen_set_scene_size_route_rejects_invalid_dimensions(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-size", data=json.dumps({"width": "oops", "height": 540}), content_type="application/json", ) assert resp.status_code == 400