From f0070facedd34aa8d39a0af29e1ec117b0c2e399 Mon Sep 17 00:00:00 2001 From: william Date: Mon, 31 Aug 2026 10:05:40 +0200 Subject: [PATCH] =?UTF-8?q?Phase=20A=20(2/2)=20:=20=C3=A9diteur=20de=20sc?= =?UTF-8?q?=C3=A8ne=202D=20=E2=80=94=20route,=20template,=20runtime=20joua?= =?UTF-8?q?ble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- routes/__init__.py | 2 +- routes/scenes/__init__.py | 6 + routes/scenes/scene_edit_view.py | 79 ++ routes/scenes/scene_object_add.py | 19 + routes/scenes/scene_object_delete.py | 15 + routes/scenes/scene_object_geometry.py | 19 + routes/scenes/scene_object_personnage_data.py | 22 + routes/screens/screen_edit.py | 7 + screens/flow/blocks/blocks_view.py | 13 +- screens/payload/full_game_payload.py | 29 +- screens/scenes/render_scene_object.py | 10 +- static/js/play/actions.js | 39 +- static/js/scenes/scene-editor.js | 145 ++++ static/js/screen_edit/flow-editor.js | 95 ++- templates/play.html | 22 + templates/scene_edit.html | 673 ++++++++++++++++++ tests/test_scene_edit_view.py | 133 ++++ 17 files changed, 1300 insertions(+), 28 deletions(-) create mode 100644 routes/scenes/__init__.py create mode 100644 routes/scenes/scene_edit_view.py create mode 100644 routes/scenes/scene_object_add.py create mode 100644 routes/scenes/scene_object_delete.py create mode 100644 routes/scenes/scene_object_geometry.py create mode 100644 routes/scenes/scene_object_personnage_data.py create mode 100644 static/js/scenes/scene-editor.js create mode 100644 templates/scene_edit.html create mode 100644 tests/test_scene_edit_view.py diff --git a/routes/__init__.py b/routes/__init__.py index eab6058d..54de4fd4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py new file mode 100644 index 00000000..216ef0bc --- /dev/null +++ b/routes/scenes/__init__.py @@ -0,0 +1,6 @@ +from . import ( + scene_object_add, + scene_object_geometry, + scene_object_delete, + scene_object_personnage_data, +) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py new file mode 100644 index 00000000..de60c1dc --- /dev/null +++ b/routes/scenes/scene_edit_view.py @@ -0,0 +1,79 @@ +import json + +from flask import render_template, request, abort + +import db +import screens + + +def render_scene_edit(slug, screen_id, game): + """Vue de l'éditeur de scène 2D — appelée par routes/screens/ + screen_edit.py (le dispatcher, selon game["game_type"]), PAS une route + Flask elle-même : même URL/endpoint "screen_edit" pour les deux types + de jeu. Réutilise TEL QUEL tout ce qui est déjà générique côté logique + de flow/animation/variables/événements (voir le plan "Fondations + d'une plateforme multi-éditeurs") — seuls les objets de scène et la + palette de nœuds proposée diffèrent de l'éditeur document.""" + screen = screens.get_screen(slug, screen_id) + if not screen: + abort(404) + objects = screens.list_scene_objects(slug, screen_id) + for o in objects: + o["rendered_html"] = screens.render_scene_object(o) + selected_id = request.args.get("selected", type=int) + personnage_data = None + selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None + if selected_obj and selected_obj["kind"] == "personnage": + personnage_data = screens.resolve_personnage_data(selected_obj) + + object_labels = { + o["id"]: (o.get("name") or ("Personnage" if o["kind"] == "personnage" else "Décor")) + for o in objects + } + element_animations_map = { + o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage" + } + + flow_blocks = screens.list_flow_blocks(slug, screen_id) + flow_nodes = screens.list_flow_nodes(slug, screen_id) + flow_edges = screens.list_flow_edges(slug, screen_id) + animation_clips = screens.list_animation_clips(slug, screen_id) + global_variables = db.list_global_variables(slug) + custom_events = db.list_custom_events(slug) + custom_event_usages = screens.list_custom_event_usages(slug) + custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events} + all_screens = screens.list_screens(slug) + screens_map = {s["id"]: s["name"] for s in all_screens} + blocks_view = screens.blocks_view(flow_blocks, flow_nodes, object_labels) + + return render_template( + "scene_edit.html", + game=game, screen=screen, all_screens=all_screens, + objects=objects, selected_id=selected_id, personnage_data=personnage_data, + sprite_library=screens.SPRITE_LIBRARY, sprite_library_json=json.dumps(screens.SPRITE_LIBRARY), + element_animations_map_json=json.dumps(element_animations_map), + element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES, + element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS), + element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES, + element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS), + trigger_events=screens.TRIGGER_EVENTS_2D, action_type_labels=screens.ACTION_TYPE_LABELS_2D, + object_action_properties=screens.OBJECT_ACTION_PROPERTIES, + element_action_property_labels_json=json.dumps(dict(screens.OBJECT_ACTION_PROPERTIES)), + condition_operators=screens.CONDITION_OPERATORS, + data_operations=screens.DATA_OPERATIONS, data_operation_labels=screens.DATA_OPERATION_LABELS, + data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS), + condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS), + action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS_2D), + elements_labels_json=json.dumps(object_labels), screens_map_json=json.dumps(screens_map), + flow_nodes=flow_nodes, flow_edges=flow_edges, + flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges), + flow_blocks_json=json.dumps(flow_blocks), blocks_view=blocks_view, + animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips), + animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), + global_variables=global_variables, + custom_events=custom_events, custom_event_usages=custom_event_usages, + custom_events_map_json=json.dumps(custom_events_map), + definitions=db.list_definitions(slug), + definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}), + definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}), + ) diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py new file mode 100644 index 00000000..fe56c9ab --- /dev/null +++ b/routes/scenes/scene_object_add.py @@ -0,0 +1,19 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//scene-objects/add", methods=["POST"]) +def scene_object_add(slug, screen_id): + """Pose un nouvel objet sur la scène — mirror de routes/elements/ + element_add.py, mais pour _scene_objects (voir screens/scenes/ + add_scene_object.py).""" + kind = request.form.get("kind", "personnage") + forge_character = request.form.get("forge_character") + new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character) + # Même route que l'éditeur document (/game//screens//edit, + # endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la + # vue de scène selon game_type, voir routes/screens/screen_edit.py. + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id)) diff --git a/routes/scenes/scene_object_delete.py b/routes/scenes/scene_object_delete.py new file mode 100644 index 00000000..e91058ee --- /dev/null +++ b/routes/scenes/scene_object_delete.py @@ -0,0 +1,15 @@ +from flask import redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//delete", methods=["POST"]) +def scene_object_delete(slug, object_id): + obj = screens.get_scene_object(slug, object_id) + if not obj: + return redirect(url_for("index")) + screen_id = obj["scene_id"] + screens.delete_scene_object(slug, object_id) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) diff --git a/routes/scenes/scene_object_geometry.py b/routes/scenes/scene_object_geometry.py new file mode 100644 index 00000000..2f264c21 --- /dev/null +++ b/routes/scenes/scene_object_geometry.py @@ -0,0 +1,19 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//geometry", methods=["POST"]) +def scene_object_geometry(slug, object_id): + """Mirror exact de routes/elements/element_geometry.py, mais en pixels + (voir screens/scenes/update_scene_object_geometry.py) — appelé en AJAX + pendant/à la fin d'un glisser-déposer sur la scène.""" + data = request.get_json(force=True) + screens.update_scene_object_geometry( + slug, object_id, + x=float(data["x"]), y=float(data["y"]), + width=float(data["width"]), height=float(data["height"]), + ) + return jsonify({"ok": True}) diff --git a/routes/scenes/scene_object_personnage_data.py b/routes/scenes/scene_object_personnage_data.py new file mode 100644 index 00000000..52d7ed74 --- /dev/null +++ b/routes/scenes/scene_object_personnage_data.py @@ -0,0 +1,22 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//personnage", methods=["POST"]) +def scene_object_personnage_data(slug, object_id): + """Mirror exact de routes/elements/element_set_personnage_data.py, + mais pour un objet de scène — même réponse (animations résolues) pour + relancer immédiatement l'aperçu animé côté client sans recharger la + page (voir static/js/screen_edit/personnage-preview.js, réutilisable + tel quel puisqu'il ne dépend que de data-object-id/data-personnage, + pas de la table d'origine).""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + screens.set_scene_object_personnage_data(slug, object_id, body) + obj = screens.get_scene_object(slug, object_id) + return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)}) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index ed66b2ef..71abac20 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -6,11 +6,18 @@ import db import screens from core.flask_app import app +from ..scenes.scene_edit_view import render_scene_edit @app.route("/game//screens//edit") def screen_edit(slug, screen_id): game = db.game_meta(slug) + if game["game_type"] == "jeu_2d": + # Fondations multi-éditeurs (voir db/games/get_game_type.py) : + # même route/endpoint pour les deux types de jeu, dispatch ici — + # tout le reste de cette fonction ne concerne QUE l'éditeur + # document ("document", le défaut, comportement inchangé). + return render_scene_edit(slug, screen_id, game) screen = screens.get_screen(slug, screen_id) if not screen: abort(404) diff --git a/screens/flow/blocks/blocks_view.py b/screens/flow/blocks/blocks_view.py index 8ec5b1a3..d282e00e 100644 --- a/screens/flow/blocks/blocks_view.py +++ b/screens/flow/blocks/blocks_view.py @@ -1,9 +1,12 @@ def blocks_view(flow_blocks, flow_nodes, elements_labels): """Enrichit chaque bloc (nom/description bruts) avec son nombre de - nœuds et les éléments qu'il concerne (trigger_element_id des - déclencheurs + target_element_id des actions, dédoublonnés) — calculé - en Python à partir de ce que routes/screens/screen_edit.py a déjà - chargé, sans requête SQL supplémentaire.""" + nœuds et les éléments qu'il concerne (trigger_element_id/ + trigger_object_id des déclencheurs + target_element_id/ + target_object_id des actions, dédoublonnés) — calculé en Python à + partir de ce que routes/screens/screen_edit.py a déjà chargé, sans + requête SQL supplémentaire. trigger_object_id/target_object_id + (fondations multi-éditeurs, objets de scène) sont toujours None sur + un jeu "document" — sans effet sur son comportement existant.""" nodes_by_block = {} for n in flow_nodes: nodes_by_block.setdefault(n.get("block_id"), []).append(n) @@ -12,6 +15,8 @@ def blocks_view(flow_blocks, flow_nodes, elements_labels): block_nodes = nodes_by_block.get(b["id"], []) element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")} element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")} + element_ids |= {n["trigger_object_id"] for n in block_nodes if n.get("trigger_object_id")} + element_ids |= {n["target_object_id"] for n in block_nodes if n.get("target_object_id")} view.append({ **b, "node_count": len(block_nodes), diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index f65e4be2..6175f705 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -7,6 +7,8 @@ from ..flow.list_flow_edges import list_flow_edges from ..animations.list_animation_clips import list_animation_clips from ..element_types.list_element_types import list_element_types from ..rendering.personnage_data import resolve_personnage_animations +from ..scenes.list_scene_objects import list_scene_objects +from ..scenes.render_scene_object import render_scene_object def full_game_payload(slug, player_id=db.PLAYER_SHARED): @@ -25,13 +27,25 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): conception), l'identifiant réel du visiteur pour une partie publique (voir core/player_identity.py) — chaque objet/variable per_player renvoie alors la valeur propre à CE joueur.""" + # Fondations multi-éditeurs — un jeu "jeu_2d" (voir db/games/ + # get_game_type.py) construit "elements" depuis _scene_objects + # (objets de scène en pixels) plutôt que _screen_elements (DOM en %) ; + # tout le reste de cette fonction (flows/animations/variables/data) + # est déjà générique et inchangé pour les deux types de jeu. + is_scene_game = db.get_game_type(slug) == "jeu_2d" screens_ = list_screens(slug) payload_screens = [] flows = {} animations = {} for s in screens_: - elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id) - payload_screens.append({**s, "elements": elements}) + if is_scene_game: + objects = list_scene_objects(slug, s["id"]) + for o in objects: + o["rendered_html"] = render_scene_object(o) + payload_screens.append({**s, "elements": objects}) + else: + elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id) + payload_screens.append({**s, "elements": elements}) # Le graphe de logique et les animations sont, eux, lus pour TOUS les # écrans, écrans-modèles compris (include_templates=True) — pas @@ -66,9 +80,14 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): "edges": list_flow_edges(slug, s["id"]), } animations[str(s["id"])] = list_animation_clips(slug, s["id"]) - for el in list_elements(slug, s["id"]): - if el.get("widget") == "personnage": - personnage_animations[str(el["id"])] = resolve_personnage_animations(el) + if is_scene_game: + for o in list_scene_objects(slug, s["id"]): + if o["kind"] == "personnage": + personnage_animations[str(o["id"])] = resolve_personnage_animations(o) + else: + for el in list_elements(slug, s["id"]): + if el.get("widget") == "personnage": + personnage_animations[str(el["id"])] = resolve_personnage_animations(el) definitions = db.list_definitions(slug) data = {} diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index e098b06b..b5418a4b 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -17,10 +17,16 @@ def render_scene_object(obj): f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']};" ) + # data-element-id (en plus de data-object-id) : réutilise TELS QUELS + # tous les sélecteurs [data-element-id=...] déjà écrits pour l'éditeur + # document (collision, animation de sprites, "Modifier un élément") — + # voir static/js/play/actions.js/conditions.js. Sans risque de + # collision d'id : un jeu est ENTIÈREMENT "document" (_screen_elements) + # OU "jeu_2d" (_scene_objects), jamais les deux à la fois. if obj["kind"] == "personnage": src = resolve_personnage_idle_frame(obj) src_attr = f' src="{html_lib.escape(src)}"' if src else "" - return f'' + return f'' src = (obj.get("attributes") or {}).get("src", "") src_attr = f' src="{html_lib.escape(src)}"' if src else "" - return f'' + return f'' diff --git a/static/js/play/actions.js b/static/js/play/actions.js index 390aa0ed..8259fd08 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -124,6 +124,29 @@ function applyElementProperty(targetEl, property, value) { targetEl.style[spec.css] = value + spec.suffix; } +// Fondations multi-éditeurs — éditeur de scène 2D : équivalent +// d'applyElementProperty() ci-dessus pour un objet de scène (action +// "Modifier un objet de scène"), mais en PIXELS (pas de %, voir +// scene_width/scene_height) — visibilite/orientation restent identiques +// à l'éditeur document (déléguées à applyElementProperty), seule la +// position change de suffixe. +function applyObjectProperty(targetEl, property, value) { + if (property === 'pos_x_relatif' || property === 'pos_y_relatif') { + const css = property === 'pos_x_relatif' ? 'left' : 'top'; + const delta = parseFloat(value); + if (isNaN(delta)) return; + const current = parseFloat(targetEl.style[css]) || 0; + targetEl.style[css] = (current + delta) + 'px'; + return; + } + if (property === 'pos_x' || property === 'pos_y') { + if (value === undefined || value === null) return; + targetEl.style[property === 'pos_x' ? 'left' : 'top'] = value + 'px'; + return; + } + applyElementProperty(targetEl, property, value); +} + function runActionNode(node) { if (node.action_type === 'ecran_suivant') { const idx = screenIndexById(window.currentScreenId); @@ -163,6 +186,15 @@ function runActionNode(node) { const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]'); if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value); return Promise.resolve(); + } else if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) { + // Fondations multi-éditeurs — éditeur de scène 2D : équivalent de + // "modifier_element" ci-dessus, mais en pixels (voir + // applyObjectProperty() plus bas) plutôt qu'en %. data-element-id + // est posé sur CHAQUE objet de scène en plus de data-object-id (voir + // render_scene_object.py), donc le même sélecteur suffit. + const targetEl = document.querySelector('[data-element-id="' + (node.target_element_id || node.target_object_id) + '"]'); + if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value); + return Promise.resolve(); } else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) { // Notification pure (voir screens/custom_events/) : ne fait que // signaler l'événement, jamais choisir un élément — c'est à @@ -240,12 +272,13 @@ function runActionNode(node) { // Fire-and-forget : ne bloque jamais la suite du graphe. if (node.data_value) new Audio(node.data_value).play().catch(function() {}); return Promise.resolve(); - } else if (node.action_type === 'jouer_animation_sprite' && node.target_element_id) { - const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]'); + } else if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) { + const spriteTargetId = node.target_element_id || node.target_object_id; + const targetEl = document.querySelector('[data-element-id="' + spriteTargetId + '"]'); if (targetEl) { let spriteData = {}; try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ } - runSpriteAnimation(targetEl, resolveSpriteFrames(node.target_element_id, spriteData)); + runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData)); } return Promise.resolve(); } diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js new file mode 100644 index 00000000..ab50cbed --- /dev/null +++ b/static/js/scenes/scene-editor.js @@ -0,0 +1,145 @@ +// ---------- Éditeur de scène 2D (fondations multi-éditeurs) ---------- +// JS propre à templates/scene_edit.html : ajout/déplacement/redimension- +// nement d'objets de scène EN PIXELS (pas de %, voir ensure_scene_schema.py), +// mirror des fonctions équivalentes de static/js/screen_edit/tree-panels.js +// pour l'éditeur document (onElementMouseDown/onResizeMouseDown/ +// saveGeometry), mais sur une scène à taille FIXE plutôt qu'un canevas +// fluide — la logique de flow elle-même (flow-editor.js, tabs-and-blocks.js, +// animation-timeline.js, personnage-panel.js, personnage-preview.js) est +// réutilisée TELLE QUELLE, chargée telle quelle par scene_edit.html. + +function addSceneObject(kind, forgeCharacter) { + var form = document.createElement('form'); + form.method = 'post'; + form.action = "/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add"; + var html = ''; + if (forgeCharacter) html += ''; + form.innerHTML = html; + document.body.appendChild(form); + form.requestSubmit(); +} + +function saveSceneObjectGeometry(id, x, y, width, height) { + return fetch("/game/" + GAME_SLUG + "/scene-objects/" + id + "/geometry", { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: x, y: y, width: width, height: height }), + }); +} + +function updateScenePosFields(x, y, w, h) { + var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y'); + var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h'); + if (xEl) xEl.value = Math.round(x); + if (yEl) yEl.value = Math.round(y); + if (wEl) wEl.value = Math.round(w); + if (hEl) hEl.value = Math.round(h); +} + +// Glisser-déposer en pixels, clampé à la taille FIXE de la scène +// (SCENE_WIDTH/SCENE_HEIGHT, voir templates/scene_edit.html) — mirror +// d'onElementMouseDown() (tree-panels.js), mais sans conversion en % : +// une scène a des coordonnées pixel absolues, pas fluides. +function onSceneObjectMouseDown(e, id) { + if (e.target.classList.contains('resizeHandle')) return; + e.preventDefault(); + var el = document.getElementById('el-' + id); + var startX = e.clientX, startY = e.clientY; + var startLeft = parseFloat(el.style.left) || 0, startTop = parseFloat(el.style.top) || 0; + var width = parseFloat(el.style.width) || 0, height = parseFloat(el.style.height) || 0; + var moved = false; + + function onMove(ev) { + var dx = ev.clientX - startX, dy = ev.clientY - startY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true; + var newLeft = Math.max(0, Math.min(SCENE_WIDTH - width, startLeft + dx)); + var newTop = Math.max(0, Math.min(SCENE_HEIGHT - height, startTop + dy)); + el.style.left = newLeft + 'px'; + el.style.top = newTop + 'px'; + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (moved) { + var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top); + updateScenePosFields(newLeft, newTop, width, height); + saveSceneObjectGeometry(id, newLeft, newTop, width, height); + } else { + window.location.href = SCREEN_EDIT_URL + '?selected=' + id; + } + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +} + +function onSceneObjectResizeMouseDown(e, id) { + e.stopPropagation(); + e.preventDefault(); + var el = document.getElementById('el-' + id); + var startX = e.clientX, startY = e.clientY; + var startWidth = parseFloat(el.style.width) || 0, startHeight = parseFloat(el.style.height) || 0; + var left = parseFloat(el.style.left) || 0, top = parseFloat(el.style.top) || 0; + + function onMove(ev) { + var dx = ev.clientX - startX, dy = ev.clientY - startY; + var newWidth = Math.max(8, Math.min(SCENE_WIDTH - left, startWidth + dx)); + var newHeight = Math.max(8, Math.min(SCENE_HEIGHT - top, startHeight + dy)); + el.style.width = newWidth + 'px'; + el.style.height = newHeight + 'px'; + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height); + updateScenePosFields(left, top, newWidth, newHeight); + saveSceneObjectGeometry(id, left, top, newWidth, newHeight); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +} + +// Champs numériques Position & taille du panneau de propriétés — mirror +// du comportement équivalent pour l'éditeur document (voir l'IIFE dans +// initBuilderPanel(), panel-init.js), mais ids/route distincts (pixels). +(function () { + var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y'); + var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h'); + if (!xEl || !wEl || !CURRENT_SELECTED_ID) return; + function apply() { + var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID); + var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0; + var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1; + if (canvasEl) { + canvasEl.style.left = x + 'px'; canvasEl.style.top = y + 'px'; + canvasEl.style.width = w + 'px'; canvasEl.style.height = h + 'px'; + } + saveSceneObjectGeometry(CURRENT_SELECTED_ID, x, y, w, h); + } + [xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); }); +})(); + +// Personnage d'un objet de scène : mirror de swapPersonnageCharacter() +// (personnage-panel.js), mais POST vers /scene-objects/.../personnage +// (voir routes/scenes/scene_object_personnage_data.py) plutôt que +// /elements/.../personnage — même réponse (animations résolues), +// relance l'aperçu animé via restartPersonnageIdlePreview() (personnage- +// preview.js, déjà générique : ne dépend que de data-element-id/ +// data-personnage, présents sur un objet de scène comme sur un élément). +function swapSceneObjectCharacter(forgeCharacter) { + var body = document.getElementById('personnagePanelBody'); + if (!body) return; + var objectId = parseInt(body.dataset.elementId, 10); + document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (tile) { + tile.classList.toggle('selected', tile.getAttribute('onclick') === "swapSceneObjectCharacter('" + forgeCharacter + "')"); + }); + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/personnage', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: 'forge', forge_character: forgeCharacter }), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + if (json.animations) { + ELEMENT_ANIMATIONS_MAP[objectId] = json.animations; + restartPersonnageIdlePreview(objectId, json.animations); + } + }); +} diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index ba12a951..2562ab35 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -6,6 +6,17 @@ // Jinja) — voir static/js/play/ pour le même patron déjà en place côté // moteur de jeu. var FLOW_SCREEN_ID = SCREEN_ID; +// Fondations multi-éditeurs — ce même fichier (moteur de logique de flow +// partagé, voir le plan "Fondations d'une plateforme multi-éditeurs") +// sert aussi bien à l'éditeur document (cible trigger_element_id/ +// target_element_id, vers _screen_elements) qu'à l'éditeur de scène 2D +// (cible trigger_object_id/target_object_id, vers _scene_objects) : posé +// à `true` par templates/scene_edit.html, sinon absent (donc falsy) sur +// templates/screen_edit.html — décide quelle paire de colonnes remplir à +// l'enregistrement d'un nœud (voir submitNodeForm()). +var FLOW_TARGETS_OBJECTS = typeof FLOW_TARGETS_OBJECTS !== 'undefined' ? FLOW_TARGETS_OBJECTS : false; +var FLOW_TRIGGER_ID_FIELD = FLOW_TARGETS_OBJECTS ? 'trigger_object_id' : 'trigger_element_id'; +var FLOW_TARGET_ID_FIELD = FLOW_TARGETS_OBJECTS ? 'target_object_id' : 'target_element_id'; // Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les // nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ; // un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas @@ -44,7 +55,7 @@ function nodeLabel(node) { return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms'; } return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' + - (ELEMENTS_LABELS[node.trigger_element_id] || '?'); + (ELEMENTS_LABELS[node.trigger_element_id || node.trigger_object_id] || '?'); } if (node.node_type === 'condition') { var clauseSubject = function(c, field, variable) { @@ -83,6 +94,18 @@ function nodeLabel(node) { base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)'; } else if (node.action_type === 'activer_onglet') { base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)'; + } else if (node.action_type === 'modifier_objet_scene') { + // Fondations multi-éditeurs — équivalent scène de "modifier_element" + // ci-dessous, palette réduite (visibilité/position en pixels/ + // orientation seulement). + base += ' → ' + (ELEMENTS_LABELS[node.target_element_id || node.target_object_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property); + if (node.element_property === 'visibilite') { + base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value); + } else if (node.element_property === 'orientation') { + base += ' = ' + (ELEMENT_ORIENTATION_LABELS_MAP[node.element_value] || node.element_value); + } else { + base += ' = ' + node.element_value; + } } else if (node.action_type === 'modifier_element') { base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property); if (node.element_property === 'visibilite') { @@ -118,7 +141,7 @@ function nodeLabel(node) { } else if (node.action_type === 'jouer_son') { base += ' « ' + (node.data_value || '?') + ' »'; } else if (node.action_type === 'jouer_animation_sprite') { - base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?'); + base += ' → ' + (ELEMENTS_LABELS[node.target_element_id || node.target_object_id] || '?'); try { var spriteData = JSON.parse(node.data_value || '{}'); base += ' « ' + (spriteData.animation || '?') + ' » (' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')'; @@ -502,6 +525,7 @@ function toggleFlowActionFields() { var typeSel = document.getElementById('nf-action-type'); var screenSel = document.getElementById('nf-action-target-screen'); var elementFields = document.getElementById('nf-action-element-fields'); + var objectFields = document.getElementById('nf-action-object-fields'); var dataFields = document.getElementById('nf-action-data-fields'); var variableFields = document.getElementById('nf-action-variable-fields'); var waitFields = document.getElementById('nf-action-wait-fields'); @@ -510,24 +534,53 @@ function toggleFlowActionFields() { var soundFields = document.getElementById('nf-action-sound-fields'); var spriteFields = document.getElementById('nf-action-sprite-fields'); 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'; + // Champs propres à l'éditeur document ci-dessous (nf-action-element-*/ + // -onglet-*) : absents du DOM de l'éditeur de scène (templates/ + // scene_edit.html), d'où les gardes "if (x)" partout — même + // #nodeForm/flow-editor.js réutilisé pour les deux éditeurs (voir + // FLOW_TARGETS_OBJECTS en tête de ce fichier). + if (soundFields) soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none'; + if (spriteFields) 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'; - variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; - waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; + if (screenSel) screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; + if (elementFields) elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; + if (objectFields) objectFields.style.display = typeSel.value === 'modifier_objet_scene' ? 'block' : 'none'; + if (dataFields) dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; + if (variableFields) variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; + if (waitFields) waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none'; if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none'; - document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none'; - document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; + var ongletHint = document.getElementById('nf-action-onglet-hint'); + if (ongletHint) ongletHint.style.display = isOnglet ? 'block' : 'none'; + var elementPropertyWrap = document.getElementById('nf-action-element-property-wrap'); + if (elementPropertyWrap) elementPropertyWrap.style.display = isOnglet ? 'none' : 'block'; if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue(); + if (typeSel.value === 'modifier_objet_scene') toggleFlowObjectPropertyValue(); if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action'); updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value'); updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value'); } +// Fondations multi-éditeurs — équivalent de toggleFlowElementPropertyValue() +// pour l'action "Modifier un objet de scène" (palette réduite : pas de +// contenu/couleur/largeur/hauteur, juste visibilité/position en pixels/ +// orientation — voir screens/scenes/flow_palette.py OBJECT_ACTION_PROPERTIES). +function toggleFlowObjectPropertyValue() { + var propSel = document.getElementById('nf-action-object-property'); + if (!propSel) return; + var visSel = document.getElementById('nf-action-object-value-visibility'); + var orientSel = document.getElementById('nf-action-object-value-orientation'); + var numInp = document.getElementById('nf-action-object-value-number'); + [visSel, orientSel, numInp].forEach(function(el) { if (el) el.style.display = 'none'; }); + if (propSel.value === 'visibilite' && visSel) { + visSel.style.display = 'block'; + } else if (propSel.value === 'orientation' && orientSel) { + orientSel.style.display = 'block'; + } else if (numInp) { + numInp.style.display = 'block'; + } +} + // "alea" (Phase 2 — hasard) attend "min,max" dans data_value (voir // screens/data_actions/compute_operation.py) plutôt qu'un seul nombre // comme les autres opérations — indice visuel dans le champ plutôt que @@ -635,7 +688,7 @@ function submitNodeForm() { } else if (payload.trigger_event === 'minuteur') { payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10); } else if (payload.trigger_event !== 'affichage') { - payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10); + payload[FLOW_TRIGGER_ID_FIELD] = parseInt(document.getElementById('nf-trigger-element').value, 10); } } else if (currentNodeFormType === 'condition') { var condSource = document.getElementById('nf-cond-source').value; @@ -717,6 +770,22 @@ function submitNodeForm() { payload.element_value = v1; } } + } else if (actionType === 'modifier_objet_scene') { + // Fondations multi-éditeurs — équivalent scène de "modifier_element" + // ci-dessus, palette réduite (voir OBJECT_ACTION_PROPERTIES) : pas + // de va-et-vient/couleur, juste visibilité/position en pixels/ + // orientation. FLOW_TARGET_ID_FIELD vaut "target_object_id" ici + // (voir tête de fichier). + payload[FLOW_TARGET_ID_FIELD] = parseInt(document.getElementById('nf-action-target-object').value, 10); + var objProp = document.getElementById('nf-action-object-property').value; + payload.element_property = objProp; + if (objProp === 'visibilite') { + payload.element_value = document.getElementById('nf-action-object-value-visibility').value; + } else if (objProp === 'orientation') { + payload.element_value = document.getElementById('nf-action-object-value-orientation').value; + } else { + payload.element_value = document.getElementById('nf-action-object-value-number').value; + } } else if (actionType === 'modifier_donnee') { payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10); payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null; @@ -754,7 +823,7 @@ function submitNodeForm() { // logique, qui continuaient à jouer les frames de l'ancien // personnage indéfiniment (bug signalé par l'utilisateur). var elementId = document.getElementById('nf-action-sprite-target-element').value; - payload.target_element_id = parseInt(elementId, 10); + payload[FLOW_TARGET_ID_FIELD] = parseInt(elementId, 10); payload.data_value = JSON.stringify({ animation: document.getElementById('nf-action-sprite-animation-name').value, fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8, diff --git a/templates/play.html b/templates/play.html index b068393c..aa097d95 100644 --- a/templates/play.html +++ b/templates/play.html @@ -27,10 +27,20 @@ } #playFrame{ position:relative; overflow:hidden; background:#0b0d12; + {% if game.game_type == 'jeu_2d' %} + /* Fondations multi-éditeurs — une scène jeu_2d a une taille FIXE en + pixels (voir scene_width/scene_height, ensure_schema.py), pas un + ratio fluide : #playFrame remplit simplement tout le viewport, la + scène elle-même se centre dedans (voir .playScreen ci-dessous et + la boucle d'écrans jeu_2d plus bas). */ + width:100vw; height:100vh; + {% else %} width: min(100vw, calc(100vh * var(--ratio-w, 9) / var(--ratio-h, 16))); height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9))); + {% endif %} } .playScreen{ position:absolute; inset:0; display:none; } + .playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; } .playElement{ position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif; } @@ -60,6 +70,17 @@
+{% if game.game_type == 'jeu_2d' %} + +{% for s in screens_data %} +
+ {% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %} +
+{% endfor %} +{% else %} {% for s in screens_data %}
{% for el in s.elements if not el.parent_id %} @@ -69,6 +90,7 @@ {% endfor %}
{% endfor %} +{% endif %} {% if not screens_data %}
Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.
{% endif %} diff --git a/templates/scene_edit.html b/templates/scene_edit.html new file mode 100644 index 00000000..1a546673 --- /dev/null +++ b/templates/scene_edit.html @@ -0,0 +1,673 @@ +{% extends "base.html" %} +{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %} +{% block content_class %}content-builder{% endblock %} +{% block body_class %}builderBody{% endblock %} +{% block extra_head %} + + +{% endblock %} +{% block content %} + +

+ 🎮 Jeu 2D — scène à taille fixe ({{ screen.scene_width }}×{{ screen.scene_height }} px). +

+ +
+ {% set obj = objects|selectattr("id", "equalto", selected_id)|first if selected_id else none %} + + +
+
+ ⋮⋮ Objets de cette scène + +
+
+
+
+
+
+ ▶️ Jouer depuis le début +
+ +

Objets de cette scène

+ {% if objects %} + + {% else %} +

Aucun objet pour l'instant.

+ {% endif %} + + +
+ 🎭 Personnages +
+
+ {% for slug_c, character in sprite_library.items() %} + + {% endfor %} +
+

Personnage Kenney prêt à l'emploi (licence libre CC0), avec toutes ses animations.

+
+
+ +
+
+ +
+
+ + + + +
+ +
+
+
+ {% for o in objects %} +
+
{{ o.rendered_html|safe }}
+
+
+ {% endfor %} +
+
+

Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner. Positions en pixels (scène à taille fixe).

+
+ +
+
+

Blocs de logique

+ +
+ + + {% if blocks_view %} + {% for b in blocks_view %} +
+ {% endfor %} +
+ + + + {% for b in blocks_view %} + + + + + + + + + + + {% endfor %} + +
NomDescriptionObjets concernésNœuds
{{ b.name }}{{ b.description|truncatesentences(3) }}{% if b.element_labels %}{{ b.element_labels|join(', ') }}{% else %}—{% endif %}{{ b.node_count }} + + +
+ +
+
+
+ {% else %} +

Aucun bloc pour l'instant — crée le premier ci-dessus, puis ouvre-le pour poser des déclencheurs/conditions/actions.

+ {% endif %} +
+ + + +
+
+ + +
+ + + +
+
+
+
+
+ +
+
+

Événements personnalisés

+ +
+ + + {% if custom_events %} + {% for ev in custom_events %} +
+ +
+ {% endfor %} +
+ + + + {% for ev in custom_events %} + + + + + + + {% endfor %} + +
NomDescriptionUtilisé par
{{ ev.name }} + {% set usages = custom_event_usages.get(ev.id, []) %} + {% if usages %} + {% for u in usages %} + {{ '🔔' if u.role == 'ecoute' else '📣' }} {{ u.screen_name }}{{ ' (modèle)' if u.is_template else '' }}{{ ', ' if not loop.last else '' }} + {% endfor %} + {% else %} + Inutilisé + {% endif %} + + +
+ + +
+
+
+ {% else %} +

Aucun événement pour l'instant — crée le premier ci-dessus, puis pose un nœud "Sur un événement personnalisé" (Déclencheur) ou "Déclencher un événement" (Action) dans la Logique de la scène.

+ {% endif %} +
+
+ + + +
+
+ ⋮⋮ Propriétés + +
+
+ {% if not selected_id or not obj %} +
Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.
+ {% else %} +
+

{{ '🏃' if obj.kind == 'personnage' else '🖼️' }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else 'Décor') }}

+
+ +
+
+ +
+ Position & taille (pixels) +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {% if obj.kind == 'personnage' %} +
+ 🎭 Personnage +
+

Personnage Forge — clique un autre pour en changer.

+
+ {% for slug_c, character in sprite_library.items() %} + + {% endfor %} +
+
+
+ {% endif %} + {% endif %} +
+
+
+ + + + + + + + + + +{% endblock %} diff --git a/tests/test_scene_edit_view.py b/tests/test_scene_edit_view.py new file mode 100644 index 00000000..ee6e9ef4 --- /dev/null +++ b/tests/test_scene_edit_view.py @@ -0,0 +1,133 @@ +"""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