From 1b7706b357f0c513190a3e4a387c3daf72117119 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 11:30:49 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20les=20blocs=20de=20logique=20:=20organ?= =?UTF-8?q?ise=20le=20graphe=20de=20flow=20en=20sous-graphes=20nomm=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le graphe de logique d'une scène s'affichait jusqu'ici sur un seul canevas plat (toutes les scènes accumulant leurs nœuds sur la même grille), ce qui ne tient pas à l'échelle dès qu'une scène évolue au fil de l'avancée du joueur et accumule des centaines/milliers de nœuds. Ajoute les "Blocs de logique" : un bloc regroupe un sous-ensemble de nœuds/arêtes d'un écran sous un nom et une description (comme une fonction). L'onglet "Logique de la scène" devient une liste de blocs (nom, description tronquée à 3 phrases, éléments concernés, nombre de nœuds, bouton "Ouvrir"). Ouvrir un bloc affiche SON graphe dans une modale plein écran, redimensionnable et déplaçable (patron déjà mûr dans game_dashboard.html, porté tel quel : makeFloatPanelDraggable/ Resizable/Fullscreenable). Décision d'architecture : un bloc est un automate FERMÉ — impossible de relier un nœud d'un bloc à un nœud d'un autre bloc (rejeté côté serveur dans flow_edge_add.py). Toute communication entre deux blocs passe par le système d'événements personnalisés déjà en place (declencher_evenement / trigger_event="evenement"). Détails techniques : - Nouvelle colonne _flow_nodes.block_id (nullable, sans FK — même rationale que trigger_element_id/target_element_id, voir screens/elements/delete_element.py) et nouvelle table _flow_blocks (screens/flow/ensure_flow_schema.py, screens/flow/blocks/ensure_flow_blocks_schema.py). - Migration douce et automatique : les nœuds posés avant l'existence des blocs (block_id NULL) sont rattachés, à la première ouverture de l'onglet, à un "Bloc principal" auto-créé (screens/flow/blocks/ list_flow_blocks.py) — aucun script de migration séparé, aucune donnée perdue. - Suppression d'un bloc = cascade complète (bloc + tous ses nœuds/ arêtes), patron identique à screens/custom_events/delete_custom_event.py mais scopé à un seul bloc plutôt que game-wide. - Routes CRUD sous routes/flow_blocks/, montées comme routes/custom_events/. - templates/screen_edit.html : FLOW (global unique) renommé en ALL_FLOW (toutes les données de l'écran) ; un seul bloc ouvert à la fois (modale unique, à la Unity) — currentBlockNodes()/currentBlockEdges() filtrent ALL_FLOW par CURRENT_BLOCK_ID à chaque rendu, sans tenir de seconde copie à synchroniser manuellement. Vérifié : 215 tests passent (7 nouveaux dans tests/test_flow_blocks.py, dont un qui verrouille l'ordre d'appel list_flow_blocks()/ list_flow_nodes() dans screen_edit.py — la migration douce doit tourner AVANT le chargement des nœuds, sinon le compte de nœuds affiché juste après une migration est périmé), syntaxe JS validée (script de screen_edit.html rendu via le client de test puis node --check). Co-Authored-By: Claude Sonnet 5 --- core/jinja_filters.py | 2 + filters/truncate_sentences_filter.py | 14 ++ routes/__init__.py | 2 +- routes/flow/flow_edge_add.py | 9 + routes/flow_blocks/__init__.py | 1 + routes/flow_blocks/flow_block_delete.py | 14 ++ routes/flow_blocks/flow_block_edit.py | 13 + routes/flow_blocks/flow_block_new.py | 14 ++ routes/screens/screen_edit.py | 9 + screens/__init__.py | 7 + screens/flow/blocks/__init__.py | 0 screens/flow/blocks/blocks_view.py | 20 ++ screens/flow/blocks/create_flow_block.py | 19 ++ screens/flow/blocks/delete_flow_block.py | 26 ++ .../flow/blocks/ensure_flow_blocks_schema.py | 24 ++ screens/flow/blocks/get_flow_block.py | 13 + screens/flow/blocks/list_flow_blocks.py | 45 ++++ screens/flow/blocks/update_flow_block.py | 17 ++ screens/flow/constants.py | 5 + screens/flow/ensure_flow_schema.py | 13 + static/style.css | 8 + styles/forge-custom.css | 8 + templates/screen_edit.html | 233 ++++++++++++++++-- tests/test_flow_blocks.py | 144 +++++++++++ 24 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 filters/truncate_sentences_filter.py create mode 100644 routes/flow_blocks/__init__.py create mode 100644 routes/flow_blocks/flow_block_delete.py create mode 100644 routes/flow_blocks/flow_block_edit.py create mode 100644 routes/flow_blocks/flow_block_new.py create mode 100644 screens/flow/blocks/__init__.py create mode 100644 screens/flow/blocks/blocks_view.py create mode 100644 screens/flow/blocks/create_flow_block.py create mode 100644 screens/flow/blocks/delete_flow_block.py create mode 100644 screens/flow/blocks/ensure_flow_blocks_schema.py create mode 100644 screens/flow/blocks/get_flow_block.py create mode 100644 screens/flow/blocks/list_flow_blocks.py create mode 100644 screens/flow/blocks/update_flow_block.py create mode 100644 tests/test_flow_blocks.py diff --git a/core/jinja_filters.py b/core/jinja_filters.py index 312293fe..184164a5 100644 --- a/core/jinja_filters.py +++ b/core/jinja_filters.py @@ -3,8 +3,10 @@ import screens from core.flask_app import app from filters.colname_filter import _colname from filters.element_style_filter import _element_style, _element_transform_style +from filters.truncate_sentences_filter import _truncate_sentences app.jinja_env.filters["colname"] = _colname app.jinja_env.filters["elstyle"] = _element_style app.jinja_env.filters["eltransform"] = _element_transform_style app.jinja_env.filters["elabel"] = screens.element_label +app.jinja_env.filters["truncatesentences"] = _truncate_sentences diff --git a/filters/truncate_sentences_filter.py b/filters/truncate_sentences_filter.py new file mode 100644 index 00000000..d0049581 --- /dev/null +++ b/filters/truncate_sentences_filter.py @@ -0,0 +1,14 @@ +import re + +_SENTENCE_END = re.compile(r"[.!?](?:\s+|$)") + + +def _truncate_sentences(text, n=3): + text = (text or "").strip() + if not text: + return "" + matches = list(_SENTENCE_END.finditer(text)) + if len(matches) <= n: + return text + cutoff = matches[n - 1].end() + return text[:cutoff].rstrip() + " …" diff --git a/routes/__init__.py b/routes/__init__.py index 66bd2092..2e9d945f 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 +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks diff --git a/routes/flow/flow_edge_add.py b/routes/flow/flow_edge_add.py index 0041965f..2aa43f8d 100644 --- a/routes/flow/flow_edge_add.py +++ b/routes/flow/flow_edge_add.py @@ -13,5 +13,14 @@ def flow_edge_add(slug, screen_id): to_node_id = int(payload["to_node_id"]) except (KeyError, TypeError, ValueError): return jsonify({"error": "nœuds invalides"}), 400 + from_node = screens.get_flow_node(slug, from_node_id) + to_node = screens.get_flow_node(slug, to_node_id) + if not from_node or not to_node: + return jsonify({"error": "nœuds invalides"}), 400 + if from_node.get("block_id") != to_node.get("block_id"): + # Un bloc de logique est un automate fermé (voir screens/flow/blocks/) : + # aucun câble entre deux blocs différents, toute communication passe + # par les événements personnalisés (declencher_evenement). + return jsonify({"error": "Impossible de relier deux nœuds de blocs différents"}), 400 edge_id = screens.add_flow_edge(slug, screen_id, from_node_id, payload.get("from_port", "out"), to_node_id) return jsonify({"id": edge_id}) diff --git a/routes/flow_blocks/__init__.py b/routes/flow_blocks/__init__.py new file mode 100644 index 00000000..1e9b3260 --- /dev/null +++ b/routes/flow_blocks/__init__.py @@ -0,0 +1 @@ +from . import flow_block_new, flow_block_edit, flow_block_delete diff --git a/routes/flow_blocks/flow_block_delete.py b/routes/flow_blocks/flow_block_delete.py new file mode 100644 index 00000000..cfb17256 --- /dev/null +++ b/routes/flow_blocks/flow_block_delete.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//flow/blocks//delete", methods=["POST"]) +def flow_block_delete(slug, screen_id, block_id): + """Supprime le bloc ET tous ses nœuds/arêtes (voir + screens/flow/blocks/delete_flow_block.py) — confirmation côté client + avant l'envoi, affichant le nombre de nœuds concernés.""" + screens.delete_flow_block(slug, block_id) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks")) diff --git a/routes/flow_blocks/flow_block_edit.py b/routes/flow_blocks/flow_block_edit.py new file mode 100644 index 00000000..01d32602 --- /dev/null +++ b/routes/flow_blocks/flow_block_edit.py @@ -0,0 +1,13 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//flow/blocks//edit", methods=["POST"]) +def flow_block_edit(slug, screen_id, block_id): + name = (request.form.get("name") or "").strip() + description = request.form.get("description") or "" + screens.update_flow_block(slug, block_id, name, description) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks")) diff --git a/routes/flow_blocks/flow_block_new.py b/routes/flow_blocks/flow_block_new.py new file mode 100644 index 00000000..e83179f4 --- /dev/null +++ b/routes/flow_blocks/flow_block_new.py @@ -0,0 +1,14 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//flow/blocks/new", methods=["POST"]) +def flow_block_new(slug, screen_id): + name = (request.form.get("name") or "").strip() + description = request.form.get("description") or "" + if name: + screens.create_flow_block(slug, screen_id, name, description) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks")) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 05b3e59b..1d666183 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -38,6 +38,11 @@ def screen_edit(slug, screen_id): definitions = db.list_definitions(slug) definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions} + # list_flow_blocks() rattache paresseusement les nœuds "orphelins" + # (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" — + # DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes + # garderait en mémoire un block_id périmé (None) pour ces nœuds. + 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) elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements} @@ -48,6 +53,8 @@ def screen_edit(slug, screen_id): custom_event_usages = screens.list_custom_event_usages(slug) custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events} + blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels) + return render_template( "screen_edit.html", game=game, screen=screen, elements=elements, top_level_elements=top_level_elements, @@ -85,4 +92,6 @@ def screen_edit(slug, screen_id): global_variables=global_variables, custom_events=custom_events, custom_event_usages=custom_event_usages, custom_events_map_json=json.dumps(custom_events_map), + blocks_view=blocks_view, + flow_blocks_json=json.dumps(flow_blocks), ) diff --git a/screens/__init__.py b/screens/__init__.py index 6cd09235..3fdcca30 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -108,6 +108,13 @@ from .flow.delete_flow_node import delete_flow_node from .flow.add_flow_edge import add_flow_edge from .flow.delete_flow_edge import delete_flow_edge +from .flow.blocks.list_flow_blocks import list_flow_blocks +from .flow.blocks.get_flow_block import get_flow_block +from .flow.blocks.create_flow_block import create_flow_block +from .flow.blocks.update_flow_block import update_flow_block +from .flow.blocks.delete_flow_block import delete_flow_block +from .flow.blocks.blocks_view import blocks_view + from .data_actions.apply_data_action import apply_data_action from .data_actions.apply_variable_action import apply_variable_action diff --git a/screens/flow/blocks/__init__.py b/screens/flow/blocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/screens/flow/blocks/blocks_view.py b/screens/flow/blocks/blocks_view.py new file mode 100644 index 00000000..8ec5b1a3 --- /dev/null +++ b/screens/flow/blocks/blocks_view.py @@ -0,0 +1,20 @@ +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.""" + nodes_by_block = {} + for n in flow_nodes: + nodes_by_block.setdefault(n.get("block_id"), []).append(n) + view = [] + for b in flow_blocks: + 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")} + view.append({ + **b, + "node_count": len(block_nodes), + "element_labels": [elements_labels[eid] for eid in element_ids if eid in elements_labels], + }) + return view diff --git a/screens/flow/blocks/create_flow_block.py b/screens/flow/blocks/create_flow_block.py new file mode 100644 index 00000000..115556b1 --- /dev/null +++ b/screens/flow/blocks/create_flow_block.py @@ -0,0 +1,19 @@ +import db + +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def create_flow_block(slug, screen_id, name, description=""): + name = (name or "").strip() + if not name: + return None + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + conn.execute( + "INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)", + (screen_id, name, (description or "").strip()), + ) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return new_id diff --git a/screens/flow/blocks/delete_flow_block.py b/screens/flow/blocks/delete_flow_block.py new file mode 100644 index 00000000..dc02f443 --- /dev/null +++ b/screens/flow/blocks/delete_flow_block.py @@ -0,0 +1,26 @@ +import db + +from ..ensure_flow_schema import ensure_flow_schema +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def delete_flow_block(slug, block_id): + """Supprime le bloc ET tout ce qu'il contient (nœuds + arêtes) — décision + explicite de l'utilisateur, un bloc de logique se comporte comme une + fonction qu'on supprime avec son corps. Patron identique à + screens/custom_events/delete_custom_event.py, mais scopé à un seul bloc + (pas game-wide) : les AUTRES blocs du même écran ne sont pas touchés.""" + ensure_flow_schema(slug) + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + conn.execute( + """DELETE FROM _flow_edges WHERE from_node_id IN + (SELECT id FROM _flow_nodes WHERE block_id = ?) + OR to_node_id IN + (SELECT id FROM _flow_nodes WHERE block_id = ?)""", + (block_id, block_id), + ) + conn.execute("DELETE FROM _flow_nodes WHERE block_id = ?", (block_id,)) + conn.execute("DELETE FROM _flow_blocks WHERE id = ?", (block_id,)) + conn.commit() + conn.close() diff --git a/screens/flow/blocks/ensure_flow_blocks_schema.py b/screens/flow/blocks/ensure_flow_blocks_schema.py new file mode 100644 index 00000000..a61415af --- /dev/null +++ b/screens/flow/blocks/ensure_flow_blocks_schema.py @@ -0,0 +1,24 @@ +import db + + +def ensure_flow_blocks_schema(slug): + """Un bloc de logique regroupe les nœuds d'UN écran (scène ou modèle) + en sous-graphe nommé/décrit, pour rester lisible même quand un écran + accumule des milliers de nœuds — voir _flow_nodes.block_id + (screens/flow/ensure_flow_schema.py). Contrairement à un événement + personnalisé (game-wide, voir db/custom_events/), un bloc n'a de sens + que pour SON écran : "name" n'est donc PAS unique globalement.""" + conn = db.connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _flow_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/screens/flow/blocks/get_flow_block.py b/screens/flow/blocks/get_flow_block.py new file mode 100644 index 00000000..9093f4cb --- /dev/null +++ b/screens/flow/blocks/get_flow_block.py @@ -0,0 +1,13 @@ +import db + +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def get_flow_block(slug, block_id): + if not block_id: + return None + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _flow_blocks WHERE id = ?", (block_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/screens/flow/blocks/list_flow_blocks.py b/screens/flow/blocks/list_flow_blocks.py new file mode 100644 index 00000000..5317642e --- /dev/null +++ b/screens/flow/blocks/list_flow_blocks.py @@ -0,0 +1,45 @@ +import db + +from ..ensure_flow_schema import ensure_flow_schema +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + +DEFAULT_BLOCK_NAME = "Bloc principal" + + +def list_flow_blocks(slug, screen_id): + """Migration douce : un écran qui a des nœuds posés AVANT l'existence + des blocs (block_id NULL) se voit attribuer, à la première consultation + de l'onglet Blocs, un bloc "Bloc principal" auto-créé qui les regroupe — + aucun script de migration séparé à faire tourner, aucune donnée perdue. + Idempotent : si "Bloc principal" existe déjà pour cet écran, on le + réutilise plutôt que d'en recréer un second.""" + ensure_flow_schema(slug) + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + orphan_count = conn.execute( + "SELECT COUNT(*) AS n FROM _flow_nodes WHERE screen_id = ? AND block_id IS NULL", + (screen_id,), + ).fetchone()["n"] + if orphan_count: + default_block = conn.execute( + "SELECT id FROM _flow_blocks WHERE screen_id = ? AND name = ?", + (screen_id, DEFAULT_BLOCK_NAME), + ).fetchone() + if default_block: + default_block_id = default_block["id"] + else: + conn.execute( + "INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)", + (screen_id, DEFAULT_BLOCK_NAME, ""), + ) + default_block_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.execute( + "UPDATE _flow_nodes SET block_id = ? WHERE screen_id = ? AND block_id IS NULL", + (default_block_id, screen_id), + ) + conn.commit() + rows = conn.execute( + "SELECT * FROM _flow_blocks WHERE screen_id = ? ORDER BY id", (screen_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/flow/blocks/update_flow_block.py b/screens/flow/blocks/update_flow_block.py new file mode 100644 index 00000000..c007f909 --- /dev/null +++ b/screens/flow/blocks/update_flow_block.py @@ -0,0 +1,17 @@ +import db + +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def update_flow_block(slug, block_id, name, description=""): + name = (name or "").strip() + if not name: + return + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + conn.execute( + "UPDATE _flow_blocks SET name = ?, description = ? WHERE id = ?", + (name, (description or "").strip(), block_id), + ) + conn.commit() + conn.close() diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 41b407b3..7f1bafed 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -86,4 +86,9 @@ FLOW_NODE_FIELDS = { # avec ses propres réglages habituels (target_element_id normal, pas # de mécanisme dynamique dédié). "trigger_custom_event_id", "target_custom_event_id", + # Blocs de logique (voir screens/flow/blocks/) : à quel bloc ce nœud + # appartient. Aucun `update_flow_node` n'existe (éditer un nœud le + # recrée puis supprime l'ancien) — le client doit donc renvoyer + # block_id à chaque (ré)création, pas juste le poser une fois. + "block_id", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index 4f59eea3..47e5a6f8 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -83,5 +83,18 @@ def ensure_flow_schema(slug): conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER") if "target_custom_event_id" not in cols: conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER") + if "block_id" not in cols: + # Blocs de logique (voir screens/flow/blocks/) : regroupe les nœuds + # d'un même écran en sous-graphes nommés/décrits pour rester lisible + # à l'échelle de milliers de nœuds. Pas de FK vers _flow_blocks — + # même rationale que trigger_element_id/target_element_id + # (screens/elements/delete_element.py) : la suppression d'un bloc + # nettoie déjà elle-même ces lignes (voir + # screens/flow/blocks/delete_flow_block.py), une contrainte ferait + # double emploi. NULL = nœud pas encore rattaché à un bloc (jeux + # créés avant cette fonctionnalité) — list_flow_blocks() les + # rattache paresseusement à un bloc "Bloc principal" généré à la + # volée, la première fois que l'onglet Blocs est ouvert. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN block_id INTEGER") conn.commit() conn.close() diff --git a/static/style.css b/static/style.css index bc2f2ee9..60bf9f27 100644 --- a/static/style.css +++ b/static/style.css @@ -666,6 +666,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p .objectPanelTabPanel{ display:none; flex-direction:column; } .objectPanelTabPanel.active{ display:flex; } +/* Modale d'un bloc de logique (voir openLogicBlockPanel() dans + screen_edit.html) : le .floatPanelBody générique n'est pas un conteneur + flex par défaut — cette classe l'y transforme pour que .nodeForm/ + .flowCanvasWrap gardent le même comportement (barre d'outils fixe, + canevas qui prend tout l'espace restant) qu'avant leur déménagement + hors de l'ancien onglet "Logique de la scène". */ +.logicBlockPanelBody{ display:flex; flex-direction:column; height:100%; } + .flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; } .nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; } diff --git a/styles/forge-custom.css b/styles/forge-custom.css index 16644ee9..dfb4729f 100644 --- a/styles/forge-custom.css +++ b/styles/forge-custom.css @@ -474,6 +474,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p .objectPanelTabPanel{ display:none; flex-direction:column; } .objectPanelTabPanel.active{ display:flex; } +/* Modale d'un bloc de logique (voir openLogicBlockPanel() dans + screen_edit.html) : le .floatPanelBody générique n'est pas un conteneur + flex par défaut — cette classe l'y transforme pour que .nodeForm/ + .flowCanvasWrap gardent le même comportement (barre d'outils fixe, + canevas qui prend tout l'espace restant) qu'avant leur déménagement + hors de l'ancien onglet "Logique de la scène". */ +.logicBlockPanelBody{ display:flex; flex-direction:column; height:100%; } + .flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; } .nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; } diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 273e5e72..8407a9e3 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -170,7 +170,7 @@ {% set current_aspect = screen.preview_aspect or '9/16' %}
- +
@@ -190,7 +190,70 @@

Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.

-
+
+
+

Blocs de logique

+ +
+ + + {% if blocks_view %} + {% for b in blocks_view %} +
+ {% endfor %} +
+ + + + {% for b in blocks_view %} + + + + + + + + + + + {% endfor %} + +
NomDescriptionÉléments 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 %} +
+ + +
@@ -2161,10 +2226,26 @@ document.querySelectorAll('.uploadInput').forEach(function(input){ // ---------- Logique de la scène : éditeur de flow à nœuds ---------- var FLOW_SCREEN_ID = {{ screen.id }}; -var FLOW = { +// 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 +// besoin d'une seconde copie filtrée à tenir synchronisée, currentBlockNodes()/ +// currentBlockEdges() recalculent la vue du bloc ouvert à chaque rendu. +var ALL_FLOW = { nodes: {{ flow_nodes_json|safe }}, edges: {{ flow_edges_json|safe }}, }; +var FLOW_BLOCKS = {{ flow_blocks_json|safe }}; +var CURRENT_BLOCK_ID = null; + +function currentBlockNodes() { + return ALL_FLOW.nodes.filter(function(n) { return n.block_id === CURRENT_BLOCK_ID; }); +} +function currentBlockEdges() { + var ids = {}; + currentBlockNodes().forEach(function(n) { ids[n.id] = true; }); + return ALL_FLOW.edges.filter(function(e) { return ids[e.from_node_id] && ids[e.to_node_id]; }); +} var ELEMENTS_LABELS = {{ elements_labels_json|safe }}; var SCREENS_MAP = {{ screens_map_json|safe }}; var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }}; @@ -2256,7 +2337,7 @@ function renderFlow() { canvas.innerHTML = ''; svg.innerHTML = ''; - FLOW.nodes.forEach(function(node) { + currentBlockNodes().forEach(function(node) { var div = document.createElement('div'); div.className = 'flowNode flowNode-' + node.node_type; div.style.left = node.x + 'px'; @@ -2322,7 +2403,7 @@ function portPosition(nodeId, side, sub) { function drawFlowEdges() { var svg = document.getElementById('flowEdges'); svg.innerHTML = ''; - FLOW.edges.forEach(function(edge) { + currentBlockEdges().forEach(function(edge) { var from = portPosition(edge.from_node_id, 'out', edge.from_port); var to = portPosition(edge.to_node_id, 'in', null); if (!from || !to) return; @@ -2348,7 +2429,7 @@ function onPortClick(nodeId, side, port) { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId }) }).then(function(r){ return r.json(); }).then(function(edge){ - FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId }); + ALL_FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId }); flowPendingPort = null; drawFlowEdges(); }); @@ -2356,15 +2437,15 @@ function onPortClick(nodeId, side, port) { function deleteFlowEdge(edgeId) { fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){ - FLOW.edges = FLOW.edges.filter(function(e){ return e.id !== edgeId; }); + ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.id !== edgeId; }); drawFlowEdges(); }); } function deleteFlowNode(nodeId) { fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){ - FLOW.nodes = FLOW.nodes.filter(function(n){ return n.id !== nodeId; }); - FLOW.edges = FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; }); + ALL_FLOW.nodes = ALL_FLOW.nodes.filter(function(n){ return n.id !== nodeId; }); + ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; }); renderFlow(); }); } @@ -2372,7 +2453,7 @@ function deleteFlowNode(nodeId) { function onFlowNodeMouseDown(e, nodeId) { if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return; e.preventDefault(); - var node = FLOW.nodes.find(function(n){ return n.id === nodeId; }); + var node = ALL_FLOW.nodes.find(function(n){ return n.id === nodeId; }); var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]'); var startX = e.clientX, startY = e.clientY; var startLeft = node.x, startTop = node.y; @@ -2659,7 +2740,7 @@ function toggleFlowElementToggleMode() { } function submitNodeForm() { - var payload = { node_type: currentNodeFormType, x: 20, y: 20 + FLOW.nodes.length * 10 }; + var payload = { node_type: currentNodeFormType, x: 20, y: 20 + currentBlockNodes().length * 10, block_id: CURRENT_BLOCK_ID }; if (currentNodeFormType === 'trigger') { payload.trigger_event = document.getElementById('nf-trigger-event').value; @@ -2759,17 +2840,12 @@ function submitNodeForm() { fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }).then(function(r){ return r.json(); }).then(function(node){ - FLOW.nodes.push(node); + ALL_FLOW.nodes.push(node); closeNodeForm(); renderFlow(); }); } -// Panneau de logique de scène absent sur un écran-modèle (élément de jeu) — -// voir le commentaire Jinja au-dessus de .builderTabs. -var HAS_FLOW_PANEL = !!document.getElementById('flowTabPanel'); -if (HAS_FLOW_PANEL) { renderFlow(); } - // ---------- Onglets du centre : Écran / Logique de la scène / Timeline // d'animation — un seul visible à la fois, chacun occupant tout l'espace // disponible (remplace l'ancien panneau du bas rétractable/redimensionnable @@ -2782,7 +2858,6 @@ function switchBuilderTab(tab) { document.querySelectorAll('.builderTabPanel').forEach(function (panel) { panel.classList.toggle('active', panel.dataset.tabPanel === tab); }); - if (tab === 'flow') requestAnimationFrame(drawFlowEdges); } function toggleDashCreate(tab) { @@ -2794,13 +2869,129 @@ function toggleDashCreate(tab) { } } +function toggleBlockEditRow(blockId) { + document.getElementById('blockEditRow' + blockId).classList.toggle('hidden'); +} + +// ---------- Panneaux flottants : glisser-déposer + redimensionnement + +// plein écran ---------- Générique, porté tel quel depuis game_dashboard.html +// (mêmes gestes partout dans l'outil plutôt qu'une variante par panneau). +function makeFloatPanelDraggable(panel, header) { + var dragging = false, offsetX = 0, offsetY = 0; + header.addEventListener('mousedown', function (e) { + if (e.target.closest('button')) return; + dragging = true; + if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit(); + var rect = panel.getBoundingClientRect(); + panel.style.left = rect.left + 'px'; + panel.style.top = rect.top + 'px'; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + panel.style.transform = 'none'; + offsetX = e.clientX - rect.left; + offsetY = e.clientY - rect.top; + document.body.style.userSelect = 'none'; + }); + document.addEventListener('mousemove', function (e) { + if (!dragging) return; + panel.style.left = (e.clientX - offsetX) + 'px'; + panel.style.top = (e.clientY - offsetY) + 'px'; + }); + document.addEventListener('mouseup', function () { + dragging = false; + document.body.style.userSelect = ''; + }); +} +function makeFloatPanelResizable(panel, handle) { + var resizing = false, startX = 0, startY = 0, startW = 0, startH = 0; + handle.addEventListener('mousedown', function (e) { + resizing = true; + if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit(); + var rect = panel.getBoundingClientRect(); + startX = e.clientX; startY = e.clientY; + startW = rect.width; startH = rect.height; + panel.style.width = startW + 'px'; + panel.style.maxWidth = 'none'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + document.addEventListener('mousemove', function (e) { + if (!resizing) return; + panel.style.width = Math.max(320, startW + (e.clientX - startX)) + 'px'; + panel.style.height = Math.max(200, startH + (e.clientY - startY)) + 'px'; + }); + document.addEventListener('mouseup', function () { + resizing = false; + document.body.style.userSelect = ''; + }); +} +function makeFloatPanelFullscreenable(panel, toggleBtn) { + var saved = null; + function enter() { + saved = { + left: panel.style.left, top: panel.style.top, right: panel.style.right, + width: panel.style.width, height: panel.style.height, + maxWidth: panel.style.maxWidth, transform: panel.style.transform, + }; + panel.classList.add('floatPanel--fullscreen'); + panel.style.left = '12px'; panel.style.top = '12px'; panel.style.right = '12px'; panel.style.bottom = '12px'; + panel.style.width = 'auto'; panel.style.height = 'auto'; panel.style.maxWidth = 'none'; panel.style.transform = 'none'; + toggleBtn.title = 'Quitter le plein écran'; + } + function exit() { + panel.classList.remove('floatPanel--fullscreen'); + panel.style.bottom = ''; + if (saved) { + panel.style.left = saved.left; panel.style.top = saved.top; panel.style.right = saved.right; + panel.style.width = saved.width; panel.style.height = saved.height; + panel.style.maxWidth = saved.maxWidth; panel.style.transform = saved.transform; + } + toggleBtn.title = 'Plein écran'; + } + toggleBtn.addEventListener('click', function () { + if (panel.classList.contains('floatPanel--fullscreen')) exit(); else enter(); + }); + panel._fsToggle = { enter: enter, exit: exit }; + return panel._fsToggle; +} + +// ---------- Modale d'un bloc de logique (voir screens/flow/blocks/) : un +// seul bloc ouvert à la fois, réutilise le même #nodeForm/#flowCanvas pour +// n'importe quel bloc — voir currentBlockNodes()/currentBlockEdges(). ---------- +(function () { + var panel = document.getElementById('logicBlockPanel'); + makeFloatPanelDraggable(panel, document.getElementById('logicBlockPanelHeader')); + makeFloatPanelResizable(panel, document.getElementById('logicBlockPanelResizeHandle')); + makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn')); + window.openLogicBlockPanel = function (blockId, blockName) { + CURRENT_BLOCK_ID = blockId; + closeNodeForm(); + document.getElementById('logicBlockPanelTitle').textContent = blockName || '?'; + panel.classList.remove('hidden'); + panel._fsToggle.enter(); + renderFlow(); + requestAnimationFrame(drawFlowEdges); + }; + window.closeLogicBlockPanel = function () { + panel.classList.add('hidden'); + CURRENT_BLOCK_ID = null; + }; +})(); + // Onglet initial : ?tab=... (utilisé par les routes de CRUD des -// événements, routes/custom_events/, pour revenir directement sur -// l'onglet "Événements" après création/modification/suppression plutôt -// que de laisser deviner où cliquer — même patron que game_dashboard.html). +// événements/blocs, pour revenir directement sur le bon onglet après +// création/modification/suppression plutôt que de laisser deviner où +// cliquer — même patron que game_dashboard.html). ?block= ouvre en +// plus directement le bloc concerné (même idiome que ?edit=&subtab= dans +// game_dashboard.html). (function () { var requestedTab = {{ (request.args.get('tab') or '')|tojson }}; if (requestedTab) switchBuilderTab(requestedTab); + var requestedBlock = {{ request.args.get('block', type=int)|tojson }}; + if (requestedBlock) { + var block = FLOW_BLOCKS.find(function (b) { return b.id === requestedBlock; }); + if (block) openLogicBlockPanel(block.id, block.name); + } })(); // ---------- Timeline d'animation : piste par élément, clips Animate.css ou diff --git a/tests/test_flow_blocks.py b/tests/test_flow_blocks.py new file mode 100644 index 00000000..e56a5069 --- /dev/null +++ b/tests/test_flow_blocks.py @@ -0,0 +1,144 @@ +"""Blocs de logique (screens/flow/blocks/) : regroupe les nœuds d'UN écran +en sous-graphes nommés/décrits pour rester lisible même à l'échelle de +milliers de nœuds (voir templates/screen_edit.html, onglet "🧩 Blocs de +logique"). Un bloc est un automate FERMÉ : aucune arête possible entre deux +nœuds de blocs différents (toute communication inter-blocs passe par les +événements personnalisés, voir tests/test_custom_events.py).""" +import json +import re + +import db +import screens + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def _add_flow_node(client, slug, screen_id, **fields): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps(fields), + content_type="application/json", + ) + assert resp.status_code == 200 + return resp.get_json()["id"] + + +def _add_flow_edge(client, slug, screen_id, from_node_id, to_node_id, from_port="out"): + return client.post( + f"/game/{slug}/screens/{screen_id}/flow/edges/add", + data=json.dumps({"from_node_id": from_node_id, "from_port": from_port, "to_node_id": to_node_id}), + content_type="application/json", + ) + + +def test_create_and_list_flow_blocks(client, game): + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/new", data={ + "name": "Ouvrir la porte", "description": "Ouvre la porte au clic.", + }) + assert resp.status_code == 302 + blocks = screens.list_flow_blocks(game, screen_id) + assert len(blocks) == 1 + assert blocks[0]["name"] == "Ouvrir la porte" + + +def test_orphan_nodes_are_migrated_into_a_default_block(client, game): + screen_id = _create_screen(client, game) + # Nœud posé "avant" l'existence des blocs (aucun block_id transmis) — + # doit être rattaché à un "Bloc principal" auto-créé au premier + # appel de list_flow_blocks, sans rien perdre. + node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage") + + blocks = screens.list_flow_blocks(game, screen_id) + assert len(blocks) == 1 + assert blocks[0]["name"] == "Bloc principal" + node = screens.get_flow_node(game, node_id) + assert node["block_id"] == blocks[0]["id"] + + # Idempotent : un second appel ne crée pas un second "Bloc principal". + blocks_again = screens.list_flow_blocks(game, screen_id) + assert len(blocks_again) == 1 + + +def test_node_created_with_a_block_id_is_not_touched_by_migration(client, game): + screen_id = _create_screen(client, game) + block_id = screens.create_flow_block(game, screen_id, "Mon bloc") + node_id = _add_flow_node( + client, game, screen_id, + node_type="trigger", trigger_event="affichage", block_id=block_id, + ) + blocks = screens.list_flow_blocks(game, screen_id) + assert len(blocks) == 1 + assert blocks[0]["id"] == block_id + assert screens.get_flow_node(game, node_id)["block_id"] == block_id + + +def test_edge_between_two_different_blocks_is_rejected(client, game): + screen_id = _create_screen(client, game) + block_a = screens.create_flow_block(game, screen_id, "Bloc A") + block_b = screens.create_flow_block(game, screen_id, "Bloc B") + node_a = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a) + node_b = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_b) + + resp = _add_flow_edge(client, game, screen_id, node_a, node_b) + assert resp.status_code == 400 + + # Une arête entre deux nœuds du MÊME bloc, elle, doit réussir. + node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a) + resp_ok = _add_flow_edge(client, game, screen_id, node_a, node_a2) + assert resp_ok.status_code == 200 + + +def test_deleting_a_block_cascades_only_its_own_nodes_and_edges(client, game): + screen_id = _create_screen(client, game) + block_a = screens.create_flow_block(game, screen_id, "Bloc A") + block_b = screens.create_flow_block(game, screen_id, "Bloc B") + + node_a1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a) + node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a) + edge_resp = _add_flow_edge(client, game, screen_id, node_a1, node_a2) + assert edge_resp.status_code == 200 + edge_id = edge_resp.get_json()["id"] + + node_b1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_b) + + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/{block_a}/delete", data={}, follow_redirects=False) + assert resp.status_code == 302 + + assert screens.get_flow_block(game, block_a) is None + assert screens.get_flow_node(game, node_a1) is None + assert screens.get_flow_node(game, node_a2) is None + assert screens.list_flow_edges(game, screen_id) == [] or all(e["id"] != edge_id for e in screens.list_flow_edges(game, screen_id)) + + # Le bloc B et son nœud, eux, ne sont pas touchés. + assert screens.get_flow_block(game, block_b) is not None + assert screens.get_flow_node(game, node_b1) is not None + + +def test_screen_edit_page_shows_correct_node_count_right_after_migration(client, game): + """routes/screens/screen_edit.py doit lancer list_flow_blocks() (qui + migre les nœuds orphelins vers "Bloc principal") AVANT list_flow_nodes() + — sinon la page affiche 0 nœud pour "Bloc principal" au tout premier + chargement suivant la migration (block_id périmé en mémoire).""" + screen_id = _create_screen(client, game) + _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage") + resp = client.get(f"/game/{game}/screens/{screen_id}/edit") + assert resp.status_code == 200 + assert b"Bloc principal" in resp.data + # Le compte de nœuds (colonne "Nœuds") doit valoir 1, pas 0 — cherche + # la cellule 1 qui suit le nom du bloc dans le tableau. + html = resp.get_data(as_text=True) + row = html[html.index("Bloc principal"):] + assert re.search(r"1", row) + + +def test_screen_edit_page_renders_blocks_tab(client, game): + screen_id = _create_screen(client, game) + screens.create_flow_block(game, screen_id, "Ouvrir la porte", "Ouvre la porte. " * 5) + resp = client.get(f"/game/{game}/screens/{screen_id}/edit") + assert resp.status_code == 200 + assert b"Blocs de logique" in resp.data + assert b"Ouvrir la porte" in resp.data