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:
william
2026-08-30 11:30:49 +02:00
co-authored by Claude Sonnet 5
parent a7a315cce7
commit 1b7706b357
24 changed files with 638 additions and 22 deletions
+2
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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})
+1
View File
@@ -0,0 +1 @@
from . import flow_block_new, flow_block_edit, flow_block_delete
+14
View File
@@ -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"))
+13
View File
@@ -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"))
+14
View File
@@ -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"))
+9
View File
@@ -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),
)
+7
View File
@@ -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
View File
+20
View File
@@ -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
+19
View File
@@ -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
+26
View File
@@ -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()
+13
View File
@@ -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
+45
View File
@@ -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]
+17
View File
@@ -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()
+5
View File
@@ -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",
}
+13
View File
@@ -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()
+8
View File
@@ -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; }
+8
View File
@@ -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; }
+212 -21
View File
@@ -170,7 +170,7 @@
{% set current_aspect = screen.preview_aspect or '9/16' %}
<div class="builderTabs" id="builderTabs">
<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="events" onclick="switchBuilderTab('events')">📣 Événements</button>
</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>
</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">
<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>
@@ -409,6 +472,8 @@
<div id="flowCanvas" class="flowCanvas"></div>
</div>
</div>
<div class="floatPanelResizeHandle" id="logicBlockPanelResizeHandle"></div>
</div>
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
<div class="flowToolbar">
@@ -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=<id> 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
+144
View File
@@ -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