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