Ajoute les blocs de logique : organise le graphe de flow en sous-graphes nommés
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a7a315cce7
commit
1b7706b357
@@ -3,8 +3,10 @@ import screens
|
|||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from filters.colname_filter import _colname
|
from filters.colname_filter import _colname
|
||||||
from filters.element_style_filter import _element_style, _element_transform_style
|
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["colname"] = _colname
|
||||||
app.jinja_env.filters["elstyle"] = _element_style
|
app.jinja_env.filters["elstyle"] = _element_style
|
||||||
app.jinja_env.filters["eltransform"] = _element_transform_style
|
app.jinja_env.filters["eltransform"] = _element_transform_style
|
||||||
app.jinja_env.filters["elabel"] = screens.element_label
|
app.jinja_env.filters["elabel"] = screens.element_label
|
||||||
|
app.jinja_env.filters["truncatesentences"] = _truncate_sentences
|
||||||
|
|||||||
@@ -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() + " …"
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -13,5 +13,14 @@ def flow_edge_add(slug, screen_id):
|
|||||||
to_node_id = int(payload["to_node_id"])
|
to_node_id = int(payload["to_node_id"])
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
return jsonify({"error": "nœuds invalides"}), 400
|
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)
|
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})
|
return jsonify({"id": edge_id})
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import flow_block_new, flow_block_edit, flow_block_delete
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/<int:block_id>/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"))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/<int:block_id>/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"))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/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"))
|
||||||
@@ -38,6 +38,11 @@ def screen_edit(slug, screen_id):
|
|||||||
definitions = db.list_definitions(slug)
|
definitions = db.list_definitions(slug)
|
||||||
definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
|
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_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||||
flow_edges = screens.list_flow_edges(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}
|
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_event_usages = screens.list_custom_event_usages(slug)
|
||||||
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
|
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(
|
return render_template(
|
||||||
"screen_edit.html",
|
"screen_edit.html",
|
||||||
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
|
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,
|
global_variables=global_variables,
|
||||||
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
||||||
custom_events_map_json=json.dumps(custom_events_map),
|
custom_events_map_json=json.dumps(custom_events_map),
|
||||||
|
blocks_view=blocks_view,
|
||||||
|
flow_blocks_json=json.dumps(flow_blocks),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ from .flow.delete_flow_node import delete_flow_node
|
|||||||
from .flow.add_flow_edge import add_flow_edge
|
from .flow.add_flow_edge import add_flow_edge
|
||||||
from .flow.delete_flow_edge import delete_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_data_action import apply_data_action
|
||||||
from .data_actions.apply_variable_action import apply_variable_action
|
from .data_actions.apply_variable_action import apply_variable_action
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -86,4 +86,9 @@ FLOW_NODE_FIELDS = {
|
|||||||
# avec ses propres réglages habituels (target_element_id normal, pas
|
# avec ses propres réglages habituels (target_element_id normal, pas
|
||||||
# de mécanisme dynamique dédié).
|
# de mécanisme dynamique dédié).
|
||||||
"trigger_custom_event_id", "target_custom_event_id",
|
"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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,5 +83,18 @@ def ensure_flow_schema(slug):
|
|||||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER")
|
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER")
|
||||||
if "target_custom_event_id" not in cols:
|
if "target_custom_event_id" not in cols:
|
||||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER")
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -666,6 +666,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
|||||||
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
||||||
.objectPanelTabPanel.active{ display:flex; }
|
.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; }
|
.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; }
|
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
|
||||||
|
|||||||
@@ -474,6 +474,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
|||||||
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
||||||
.objectPanelTabPanel.active{ display:flex; }
|
.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; }
|
.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; }
|
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
|
||||||
|
|||||||
+212
-21
@@ -170,7 +170,7 @@
|
|||||||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||||||
<div class="builderTabs" id="builderTabs">
|
<div class="builderTabs" id="builderTabs">
|
||||||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
|
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
|
||||||
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
|
<button type="button" class="builderTabBtn" data-tab="blocks" onclick="switchBuilderTab('blocks')">🧩 Blocs de logique</button>
|
||||||
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
|
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
|
||||||
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
|
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +190,70 @@
|
|||||||
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
|
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="builderTabPanel" id="flowTabPanel" data-tab-panel="flow">
|
<div class="builderTabPanel" id="blocksTabPanel" data-tab-panel="blocks">
|
||||||
|
<div class="dashPanelHeader">
|
||||||
|
<h2>Blocs de logique</h2>
|
||||||
|
<button type="button" class="button is-small primary" onclick="toggleDashCreate('blocks')">+ Créer un bloc</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('flow_block_new', slug=game.slug, screen_id=screen.id) }}" class="dashCreateBar" id="dashCreate-blocks" style="display:none;">
|
||||||
|
<input type="text" name="name" placeholder="Nom du bloc (ex. Ouvrir la porte)" required autocomplete="off">
|
||||||
|
<input type="text" name="description" placeholder="Description (optionnel)">
|
||||||
|
<button type="submit" class="primary">Créer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if blocks_view %}
|
||||||
|
{% for b in blocks_view %}
|
||||||
|
<form method="post" action="{{ url_for('flow_block_edit', slug=game.slug, screen_id=screen.id, block_id=b.id) }}" id="blockEditForm{{ b.id }}"></form>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="fieldsTableWrap">
|
||||||
|
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||||
|
<thead><tr><th>Nom</th><th>Description</th><th>Éléments concernés</th><th>Nœuds</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in blocks_view %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ b.name }}</strong></td>
|
||||||
|
<td>{{ b.description|truncatesentences(3) }}</td>
|
||||||
|
<td>{% if b.element_labels %}{{ b.element_labels|join(', ') }}{% else %}<span class="hint">—</span>{% endif %}</td>
|
||||||
|
<td>{{ b.node_count }}</td>
|
||||||
|
<td class="fieldsTableActions">
|
||||||
|
<button type="button" class="button is-small is-primary" onclick="openLogicBlockPanel({{ b.id }}, {{ b.name|tojson }})" title="Ouvrir le graphe de ce bloc">🧩 Ouvrir</button>
|
||||||
|
<button type="button" class="button is-small" onclick="toggleBlockEditRow({{ b.id }})" title="Modifier le nom/la description">✏️</button>
|
||||||
|
<form method="post" action="{{ url_for('flow_block_delete', slug=game.slug, screen_id=screen.id, block_id=b.id) }}"
|
||||||
|
onsubmit="return confirm('Supprimer le bloc « {{ b.name }} » ? Cela supprime aussi ses {{ b.node_count }} nœud(s) et leurs connexions.');" style="display:inline;">
|
||||||
|
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="hidden" id="blockEditRow{{ b.id }}">
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="dashCreateBar" style="display:flex;">
|
||||||
|
<input type="text" form="blockEditForm{{ b.id }}" name="name" value="{{ b.name }}" required>
|
||||||
|
<input type="text" form="blockEditForm{{ b.id }}" name="description" value="{{ b.description or '' }}" placeholder="Description">
|
||||||
|
<button type="submit" form="blockEditForm{{ b.id }}" class="primary">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">Aucun bloc pour l'instant — crée le premier ci-dessus, puis ouvre-le pour poser des déclencheurs/conditions/actions.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale plein écran/redimensionnable/déplaçable d'un bloc de logique
|
||||||
|
(voir openLogicBlockPanel()) — patron makeFloatPanelDraggable/
|
||||||
|
Resizable/Fullscreenable, identique à game_dashboard.html. Un seul
|
||||||
|
bloc ouvert à la fois : ce même #nodeForm/#flowCanvas est réutilisé
|
||||||
|
pour n'importe quel bloc (voir CURRENT_BLOCK_ID). -->
|
||||||
|
<div class="floatPanel floatPanel--center hidden" id="logicBlockPanel">
|
||||||
|
<div class="floatPanelHeader" id="logicBlockPanelHeader">
|
||||||
|
<span class="floatPanelTitle">⋮⋮ <span id="logicBlockPanelTitle">Bloc</span></span>
|
||||||
|
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
|
||||||
|
<button type="button" class="button is-small" onclick="closeLogicBlockPanel()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="floatPanelBody logicBlockPanelBody">
|
||||||
<div class="flowToolbar">
|
<div class="flowToolbar">
|
||||||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||||||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
|
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
|
||||||
@@ -408,6 +471,8 @@
|
|||||||
<svg id="flowEdges" class="flowEdgesSvg"></svg>
|
<svg id="flowEdges" class="flowEdgesSvg"></svg>
|
||||||
<div id="flowCanvas" class="flowCanvas"></div>
|
<div id="flowCanvas" class="flowCanvas"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="floatPanelResizeHandle" id="logicBlockPanelResizeHandle"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
|
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
|
||||||
@@ -2161,10 +2226,26 @@ document.querySelectorAll('.uploadInput').forEach(function(input){
|
|||||||
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
|
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
|
||||||
|
|
||||||
var FLOW_SCREEN_ID = {{ screen.id }};
|
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 }},
|
nodes: {{ flow_nodes_json|safe }},
|
||||||
edges: {{ flow_edges_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 ELEMENTS_LABELS = {{ elements_labels_json|safe }};
|
||||||
var SCREENS_MAP = {{ screens_map_json|safe }};
|
var SCREENS_MAP = {{ screens_map_json|safe }};
|
||||||
var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }};
|
var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }};
|
||||||
@@ -2256,7 +2337,7 @@ function renderFlow() {
|
|||||||
canvas.innerHTML = '';
|
canvas.innerHTML = '';
|
||||||
svg.innerHTML = '';
|
svg.innerHTML = '';
|
||||||
|
|
||||||
FLOW.nodes.forEach(function(node) {
|
currentBlockNodes().forEach(function(node) {
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.className = 'flowNode flowNode-' + node.node_type;
|
div.className = 'flowNode flowNode-' + node.node_type;
|
||||||
div.style.left = node.x + 'px';
|
div.style.left = node.x + 'px';
|
||||||
@@ -2322,7 +2403,7 @@ function portPosition(nodeId, side, sub) {
|
|||||||
function drawFlowEdges() {
|
function drawFlowEdges() {
|
||||||
var svg = document.getElementById('flowEdges');
|
var svg = document.getElementById('flowEdges');
|
||||||
svg.innerHTML = '';
|
svg.innerHTML = '';
|
||||||
FLOW.edges.forEach(function(edge) {
|
currentBlockEdges().forEach(function(edge) {
|
||||||
var from = portPosition(edge.from_node_id, 'out', edge.from_port);
|
var from = portPosition(edge.from_node_id, 'out', edge.from_port);
|
||||||
var to = portPosition(edge.to_node_id, 'in', null);
|
var to = portPosition(edge.to_node_id, 'in', null);
|
||||||
if (!from || !to) return;
|
if (!from || !to) return;
|
||||||
@@ -2348,7 +2429,7 @@ function onPortClick(nodeId, side, port) {
|
|||||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId })
|
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){
|
}).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;
|
flowPendingPort = null;
|
||||||
drawFlowEdges();
|
drawFlowEdges();
|
||||||
});
|
});
|
||||||
@@ -2356,15 +2437,15 @@ function onPortClick(nodeId, side, port) {
|
|||||||
|
|
||||||
function deleteFlowEdge(edgeId) {
|
function deleteFlowEdge(edgeId) {
|
||||||
fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){
|
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();
|
drawFlowEdges();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteFlowNode(nodeId) {
|
function deleteFlowNode(nodeId) {
|
||||||
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
|
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
|
||||||
FLOW.nodes = FLOW.nodes.filter(function(n){ return n.id !== nodeId; });
|
ALL_FLOW.nodes = ALL_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.edges = ALL_FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; });
|
||||||
renderFlow();
|
renderFlow();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2372,7 +2453,7 @@ function deleteFlowNode(nodeId) {
|
|||||||
function onFlowNodeMouseDown(e, nodeId) {
|
function onFlowNodeMouseDown(e, nodeId) {
|
||||||
if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return;
|
if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return;
|
||||||
e.preventDefault();
|
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 div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
|
||||||
var startX = e.clientX, startY = e.clientY;
|
var startX = e.clientX, startY = e.clientY;
|
||||||
var startLeft = node.x, startTop = node.y;
|
var startLeft = node.x, startTop = node.y;
|
||||||
@@ -2659,7 +2740,7 @@ function toggleFlowElementToggleMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submitNodeForm() {
|
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') {
|
if (currentNodeFormType === 'trigger') {
|
||||||
payload.trigger_event = document.getElementById('nf-trigger-event').value;
|
payload.trigger_event = document.getElementById('nf-trigger-event').value;
|
||||||
@@ -2759,17 +2840,12 @@ function submitNodeForm() {
|
|||||||
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), {
|
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), {
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||||||
}).then(function(r){ return r.json(); }).then(function(node){
|
}).then(function(r){ return r.json(); }).then(function(node){
|
||||||
FLOW.nodes.push(node);
|
ALL_FLOW.nodes.push(node);
|
||||||
closeNodeForm();
|
closeNodeForm();
|
||||||
renderFlow();
|
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
|
// ---------- Onglets du centre : Écran / Logique de la scène / Timeline
|
||||||
// d'animation — un seul visible à la fois, chacun occupant tout l'espace
|
// d'animation — un seul visible à la fois, chacun occupant tout l'espace
|
||||||
// disponible (remplace l'ancien panneau du bas rétractable/redimensionnable
|
// disponible (remplace l'ancien panneau du bas rétractable/redimensionnable
|
||||||
@@ -2782,7 +2858,6 @@ function switchBuilderTab(tab) {
|
|||||||
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
|
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
|
||||||
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
|
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
|
||||||
});
|
});
|
||||||
if (tab === 'flow') requestAnimationFrame(drawFlowEdges);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDashCreate(tab) {
|
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
|
// Onglet initial : ?tab=... (utilisé par les routes de CRUD des
|
||||||
// événements, routes/custom_events/, pour revenir directement sur
|
// événements/blocs, pour revenir directement sur le bon onglet après
|
||||||
// l'onglet "Événements" après création/modification/suppression plutôt
|
// création/modification/suppression plutôt que de laisser deviner où
|
||||||
// que de laisser deviner où cliquer — même patron que game_dashboard.html).
|
// cliquer — même patron que game_dashboard.html). ?block=<id> ouvre en
|
||||||
|
// plus directement le bloc concerné (même idiome que ?edit=&subtab= dans
|
||||||
|
// game_dashboard.html).
|
||||||
(function () {
|
(function () {
|
||||||
var requestedTab = {{ (request.args.get('tab') or '')|tojson }};
|
var requestedTab = {{ (request.args.get('tab') or '')|tojson }};
|
||||||
if (requestedTab) switchBuilderTab(requestedTab);
|
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
|
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
|
||||||
|
|||||||
@@ -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 <td>1</td> 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"<td>1</td>", 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
|
||||||
Reference in New Issue
Block a user