Ajoute la condition de visibilité et les variables globales

Nouveau panneau "Condition de visibilité" disponible dans les propriétés
de TOUT élément (widget) : permet de masquer un élément en mode jouable
selon deux moyens, au choix -
  - une variable globale (nom + type + valeur, une seule par jeu, stockée
    dans une nouvelle table _global_variables) ;
  - le champ d'un objet de données existant (même convention "état de
    partie" - une seule ligne - déjà utilisée par la Jauge).

Une variable ne servant à rien si elle ne peut jamais changer en cours de
partie, ajoute aussi une nouvelle action de flow "Modifier une variable
globale" (parallèle à "Modifier une donnée"), avec sa propre route
d'exécution serveur et son sous-formulaire dans l'éditeur de logique de
scène. Une variable peut aussi se créer à la volée depuis le sélecteur du
panneau de visibilité, sans quitter les propriétés de l'élément.

La condition n'est évaluée qu'en mode jouable (/game/<slug>/play), jamais
dans l'éditeur, pour que l'élément reste toujours sélectionnable. Un
élément masqué se réévalue en direct après toute action "Modifier une
donnée/variable", via le même mécanisme de rafraîchissement déjà utilisé
par la Jauge et le Répéteur.

Corrige au passage deux bugs découverts en testant bout en bout : (1)
apply_ctx plantait sur le nouveau marqueur interne _forge_play_mode (un
booléen parmi les {{champ}} à substituer, qui attend des chaînes) ; (2)
_compare traitait toute valeur booléenne stockée en chaîne ("0" inclus,
donc toujours vraie en Python) comme vraie - correct pour les champs
d'objet (entiers SQLite) mais faux pour les variables globales (toujours
stockées en texte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-25 09:34:10 +02:00
co-authored by Claude Sonnet 5
parent 3c6d07cb3a
commit c04bc0b926
30 changed files with 436 additions and 20 deletions
+8
View File
@@ -52,6 +52,12 @@ from .rows.update_row_field import update_row_field
from .rows.delete_row import delete_row from .rows.delete_row import delete_row
from .rows.rows_referencing import rows_referencing from .rows.rows_referencing import rows_referencing
from .global_vars.list_global_variables import list_global_variables
from .global_vars.get_global_variable import get_global_variable
from .global_vars.create_global_variable import create_global_variable
from .global_vars.update_global_variable_value import update_global_variable_value
from .global_vars.delete_global_variable import delete_global_variable
__all__ = [ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "PROJECTS_DIR", "FIELD_TYPES",
"slugify", "table_name_for", "game_dir", "db_path", "connect", "slugify", "table_name_for", "game_dir", "db_path", "connect",
@@ -61,4 +67,6 @@ __all__ = [
"delete_definition", "delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row", "list_rows", "relation_options", "insert_row", "get_row", "update_row",
"update_row_field", "delete_row", "rows_referencing", "update_row_field", "delete_row", "rows_referencing",
"list_global_variables", "get_global_variable", "create_global_variable",
"update_global_variable_value", "delete_global_variable",
] ]
View File
+27
View File
@@ -0,0 +1,27 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def create_global_variable(slug, name, var_type="texte", default_value=""):
"""Crée une variable globale si son nom n'existe pas déjà (les noms sont
uniques — voir ensure_global_vars_schema.py) ; si elle existe déjà, ne
touche à rien et renvoie simplement son id existant, pour que "créer"
depuis le sélecteur de condition de visibilité (voir screen_edit.html)
reste sans risque même en cas de double clic/soumission."""
name = (name or "").strip()
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone()
if existing:
conn.close()
return existing["id"]
conn.execute(
"INSERT INTO _global_variables (name, type, value) VALUES (?, ?, ?)",
(name, var_type, default_value),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def delete_global_variable(slug, name):
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("DELETE FROM _global_variables WHERE name = ?", (name,))
conn.commit()
conn.close()
@@ -0,0 +1,23 @@
from ..connection import connect
def ensure_global_vars_schema(slug):
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
crée _global_variables si absente — aucune migration manuelle nécessaire
pour les jeux créés avant cette fonctionnalité, la prochaine lecture
suffit. Une variable globale vit pour TOUT le jeu (pas par écran, pas
par ligne) : "nom" est donc UNIQUE, une seule valeur actuelle par nom."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _global_variables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'texte',
value TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def get_global_variable(slug, name):
"""Par NOM (unique), pas par id — c'est comme ça qu'une variable est
référencée partout ailleurs (condition de visibilité, action "Modifier
une variable"), jamais par un id technique que l'utilisateur ne voit
pas."""
if not name:
return None
ensure_global_vars_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _global_variables WHERE name = ?", (name,)).fetchone()
conn.close()
return dict(row) if row else None
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
def list_global_variables(slug):
ensure_global_vars_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _global_variables ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
@@ -0,0 +1,18 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .get_global_variable import get_global_variable
def update_global_variable_value(slug, name, new_value):
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant) sur la
variable — utilisé aussi bien par l'action "Modifier une variable" (voir
screens/global_vars/apply_variable_action.py) que par une éventuelle
modification directe (page de gestion des variables)."""
if not get_global_variable(slug, name):
return False
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET value = ? WHERE name = ?", (str(new_value), name))
conn.commit()
conn.close()
return True
+1 -1
View File
@@ -1 +1 @@
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
+1
View File
@@ -5,5 +5,6 @@ from . import (
flow_edge_add, flow_edge_add,
flow_edge_delete, flow_edge_delete,
flow_node_run_data, flow_node_run_data,
flow_node_run_variable,
runtime_payload, runtime_payload,
) )
+17
View File
@@ -0,0 +1,17 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable(slug, node_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud
Action de type "Modifier une variable globale" — même principe que
flow_node_run_data.py, pour une variable plutôt qu'un champ d'objet."""
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(slug, node)
return jsonify({"ok": ok})
+1
View File
@@ -0,0 +1 @@
from .create_global_var import create_global_var
+19
View File
@@ -0,0 +1,19 @@
from flask import jsonify, request
import db
from core.flask_app import app
@app.route("/game/<slug>/global-vars", methods=["POST"])
def create_global_var(slug):
"""Création rapide d'une variable globale depuis le sélecteur "Variable"
de la Condition de visibilité (voir screen_edit.html, openVisCondVariablePrompt) —
évite d'avoir à quitter le panneau de propriétés pour en créer une."""
name = (request.form.get("name") or "").strip()
var_type = request.form.get("type") or "texte"
default_value = request.form.get("default_value") or ""
if not name:
return jsonify({"ok": False, "error": "Nom manquant"}), 400
var_id = db.create_global_variable(slug, name, var_type, default_value)
return jsonify({"ok": True, "id": var_id, "name": name})
+2
View File
@@ -38,6 +38,7 @@ def screen_edit(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}
screens_map = {s["id"]: s["name"] for s in all_screens} screens_map = {s["id"]: s["name"] for s in all_screens}
animation_clips = screens.list_animation_clips(slug, screen_id) animation_clips = screens.list_animation_clips(slug, screen_id)
global_variables = db.list_global_variables(slug)
return render_template( return render_template(
"screen_edit.html", "screen_edit.html",
@@ -74,4 +75,5 @@ def screen_edit(slug, screen_id):
animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_catalog=screens.ANIMATE_CSS_CATALOG,
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
fontawesome_icons=screens.FONTAWESOME_ICONS, fontawesome_icons=screens.FONTAWESOME_ICONS,
global_variables=global_variables,
) )
+1
View File
@@ -107,6 +107,7 @@ 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 .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 .animations.ensure_animation_schema import ensure_animation_schema from .animations.ensure_animation_schema import ensure_animation_schema
from .animations.list_animation_clips import list_animation_clips from .animations.list_animation_clips import list_animation_clips
@@ -0,0 +1,53 @@
import db
def apply_variable_action(slug, action):
"""Exécute au moment du clic (mode jouable) une action
"modifier_variable" — même logique/vocabulaire d'opérations que
apply_data_action.py (DATA_OPERATIONS), appliqué à une variable globale
(une seule valeur pour tout le jeu) plutôt qu'au champ d'une ligne
d'objet."""
var_name = action.get("target_variable")
operation = action.get("data_operation")
raw_value = action.get("data_value")
if not (var_name and operation):
return False
variable = db.get_global_variable(slug, var_name)
if not variable:
return False
var_type = variable["type"]
current = variable.get("value")
if var_type in ("nombre_entier", "nombre_decimal"):
try:
current = float(current) if current not in (None, "") else 0
except (TypeError, ValueError):
current = 0
elif var_type == "booleen":
current = 1 if str(current).strip().lower() in ("1", "true", "vrai") else 0
if operation == "definir_texte":
new_value = raw_value or ""
elif operation == "definir_nombre":
try:
new_value = float(raw_value) if var_type == "nombre_decimal" else int(float(raw_value))
except (TypeError, ValueError):
new_value = 0
elif operation == "definir_bool_vrai":
new_value = 1
elif operation == "definir_bool_faux":
new_value = 0
elif operation == "basculer_bool":
new_value = 0 if current else 1
elif operation in ("incrementer", "decrementer"):
try:
amount = float(raw_value) if raw_value not in (None, "") else 0
except (TypeError, ValueError):
amount = 0
if operation == "decrementer":
amount = -amount
result = (current or 0) + amount
new_value = result if var_type == "nombre_decimal" else int(result)
else:
return False
return db.update_global_variable_value(slug, var_name, new_value)
+10 -3
View File
@@ -5,14 +5,20 @@ import db
from ..rendering.render_element_html import render_element_html from ..rendering.render_element_html import render_element_html
def list_elements(slug, screen_id): def list_elements(slug, screen_id, enforce_visibility=False):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique """Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
les éléments de premier niveau (parent_id NULL) reçoivent un les éléments de premier niveau (parent_id NULL) reçoivent un
"rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut "rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut
déjà, en le générant récursivement, le HTML de tout ce qui est posé à déjà, en le générant récursivement, le HTML de tout ce qui est posé à
l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
deuxième fois séparément.""" deuxième fois séparément.
enforce_visibility : True uniquement pour le mode jouable (voir
full_game_payload.py) — applique la "condition de visibilité" de chaque
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
qui doit continuer à montrer/sélectionner un élément même si sa
condition serait actuellement fausse."""
conn = db.connect(slug) conn = db.connect(slug)
rows = conn.execute( rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,) "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
@@ -28,7 +34,8 @@ def list_elements(slug, screen_id):
for d in result: for d in result:
if d.get("parent_id"): if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d) children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
for d in result: for d in result:
if not d.get("parent_id"): if not d.get("parent_id"):
d["rendered_html"] = render_element_html(d, slug, children_map) d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
return result return result
+1
View File
@@ -53,4 +53,5 @@ FLOW_NODE_FIELDS = {
"cond_clauses", "cond_combinator", "cond_clauses", "cond_combinator",
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value", "action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value", "target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"target_variable",
} }
+5
View File
@@ -51,5 +51,10 @@ def ensure_flow_schema(slug):
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT")
if "cond_combinator" not in cols: if "cond_combinator" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'")
if "target_variable" not in cols:
# Action "Modifier une variable" (voir apply_variable_action.py) —
# cible une variable globale par NOM plutôt qu'un champ d'objet ;
# réutilise data_operation/data_value, déjà là pour "modifier_donnee".
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT")
conn.commit() conn.commit()
conn.close() conn.close()
+1
View File
@@ -4,6 +4,7 @@ ACTION_TYPE_LABELS = {
"aller_a": "Aller à un écran précis", "aller_a": "Aller à un écran précis",
"modifier_element": "Modifier un élément", "modifier_element": "Modifier un élément",
"modifier_donnee": "Modifier une donnée d'un objet", "modifier_donnee": "Modifier une donnée d'un objet",
"modifier_variable": "Modifier une variable globale",
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères", "activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer", "attendre": "Attendre quelques secondes avant de continuer",
+1 -1
View File
@@ -21,7 +21,7 @@ def full_game_payload(slug):
flows = {} flows = {}
animations = {} animations = {}
for s in screens_: for s in screens_:
elements = list_elements(slug, s["id"]) elements = list_elements(slug, s["id"], enforce_visibility=True)
payload_screens.append({**s, "elements": elements}) payload_screens.append({**s, "elements": elements})
flows[str(s["id"])] = { flows[str(s["id"])] = {
"nodes": list_flow_nodes(slug, s["id"]), "nodes": list_flow_nodes(slug, s["id"]),
+5
View File
@@ -5,5 +5,10 @@ def _apply_ctx(text, ctx):
if not ctx or not text: if not ctx or not text:
return text return text
for k, v in ctx.items(): for k, v in ctx.items():
# "_forge_play_mode" (voir render_element_html.py) est un indicateur
# interne posé dans ce même ctx, pas une valeur de champ {{...}} —
# à ignorer ici.
if k == "_forge_play_mode":
continue
text = text.replace("{{" + k + "}}", v) text = text.replace("{{" + k + "}}", v)
return text return text
+9 -1
View File
@@ -51,7 +51,15 @@ def _compare(actual, operator, expected, field_type):
"""Équivalent Python de compareValues() dans templates/play.html — les """Équivalent Python de compareValues() dans templates/play.html — les
deux doivent rester alignées si on ajoute un opérateur.""" deux doivent rester alignées si on ajoute un opérateur."""
if field_type == "booleen": if field_type == "booleen":
a = 1 if actual else 0 # "actual" est un int SQLite (0/1) pour un champ d'objet, mais une
# chaîne pour une variable globale (voir db/global_vars/, toujours
# stockée en TEXT) — "0" est une chaîne non vide donc VRAIE en
# Python, d'où ce traitement explicite plutôt qu'un simple bool(...).
if isinstance(actual, str):
truthy = actual.strip().lower() not in ("", "0", "false", "faux", "none")
else:
truthy = bool(actual)
a = 1 if truthy else 0
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0 e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0
return a != e if operator == "different" else a == e return a != e if operator == "different" else a == e
try: try:
+26 -4
View File
@@ -16,6 +16,7 @@ from .render_overlay import _render_overlay
from .render_onglets import _render_onglets from .render_onglets import _render_onglets
from .render_icone import _render_icone from .render_icone import _render_icone
from .resolve_bound_row import _resolve_bound_row_ctx from .resolve_bound_row import _resolve_bound_row_ctx
from .visibility_condition import is_element_visible
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None): def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
@@ -31,7 +32,28 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
pose physiquement cet élément, s'il y en a un — elle influence la largeur pose physiquement cet élément, s'il y en a un — elle influence la largeur
par défaut de cet élément (voir _style_string).""" par défaut de cet élément (voir _style_string)."""
meta = widget_meta(el.get("widget")) meta = widget_meta(el.get("widget"))
bound_ctx = _resolve_bound_row_ctx(slug, el.get("attributes") or {}) attrs_raw = el.get("attributes") or {}
# La condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) n'est
# appliquée qu'en mode JOUABLE (ctx["_forge_play_mode"], posé par
# full_game_payload.py via list_elements(..., enforce_visibility=True))
# — jamais dans l'éditeur, où l'élément doit rester visible/sélectionnable
# même si la condition serait actuellement fausse, sans quoi il
# deviendrait impossible de le retrouver pour le régler. Un commentaire
# marqueur ("visibilityGated") reste posé sur le HTML même quand la
# condition est vraie, pour que refreshRuntimeData() (play.html) sache
# qu'il faut réévaluer cet élément après toute action "Modifier une
# donnée"/"Modifier une variable" — pas seulement Jauge/Répéteur, qui ont
# déjà chacun leur propre marqueur ("jaugeBar"/"repeaterItem") couvrant
# ce cas d'office.
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
play_mode = bool((ctx or {}).get("_forge_play_mode"))
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw):
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
def _mark(html_str):
return html_str + "<!--visibilityGated-->" if has_visibility_condition else html_str
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw)
if bound_ctx: if bound_ctx:
ctx = {**(ctx or {}), **bound_ctx} ctx = {**(ctx or {}), **bound_ctx}
special = meta.get("special_render") special = meta.get("special_render")
@@ -72,7 +94,7 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx) attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
if meta.get("self_closing"): if meta.get("self_closing"):
return f"<{tag} {_attr_string(attrs, style)}>" return _mark(f"<{tag} {_attr_string(attrs, style)}>")
content = _apply_ctx(el.get("content") or "", ctx) content = _apply_ctx(el.get("content") or "", ctx)
child_html = _render_children(el, slug, children_map, ctx) child_html = _render_children(el, slug, children_map, ctx)
@@ -80,7 +102,7 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
if tag in ("ul", "ol"): if tag in ("ul", "ol"):
items = [line.strip() for line in content.split("\n") if line.strip()] items = [line.strip() for line in content.split("\n") if line.strip()]
inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
return f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>" return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else "" safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>" return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
+44
View File
@@ -0,0 +1,44 @@
import db
from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column
def is_element_visible(slug, attrs):
"""Condition de visibilité (panneau de propriétés, tout widget — voir
VISIBILITY_CONDITION_CONTROLS) : masque un élément selon soit une
VARIABLE GLOBALE (une seule valeur pour tout le jeu, voir
db/global_vars/), soit le champ d'un objet de données (convention "1
seule ligne = état de partie", déjà utilisée par la Jauge/Donnée liée).
Absente de réglage (_visibilite_cond_mode vide) => toujours visible,
comportement inchangé pour tous les éléments déjà créés. Réutilise
_compare/_resolve_filter_value/_field_column de filter_repeater_rows.py
pour rester cohérent avec les mêmes opérateurs et la même syntaxe
"{{Objet.champ}}" pour une valeur de comparaison dynamique."""
mode = attrs.get("_visibilite_cond_mode")
if mode not in ("variable", "objet"):
return True
operator = attrs.get("_visibilite_cond_operateur") or "egal"
expected = _resolve_filter_value(slug, attrs.get("_visibilite_cond_valeur") or "")
if mode == "variable":
var_name = attrs.get("_visibilite_cond_variable")
if not var_name:
return True
variable = db.get_global_variable(slug, var_name)
if not variable:
return True # variable supprimée depuis : ne bloque jamais l'affichage
return _compare(variable.get("value"), operator, expected, variable.get("type") or "texte")
definition_id = attrs.get("_visibilite_cond_definition_id")
field_name = attrs.get("_visibilite_cond_champ")
if not definition_id or not field_name:
return True
definition = db.get_definition(slug, int(definition_id))
if not definition:
return True
rows = db.list_rows(slug, definition)
if not rows:
return True # objet sans donnée pour l'instant : ne bloque pas
row = rows[0] # convention "état de partie" : une seule ligne, voir data_definition_options.py
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
return _compare(row.get(_field_column(definition, field_name)), operator, expected, field_type)
@@ -1,5 +1,6 @@
from ..controls.c_scale import c_scale from ..controls.c_scale import c_scale
from .visibility_control import VISIBILITY_CONTROL from .visibility_control import VISIBILITY_CONTROL
from .visibility_condition_controls import VISIBILITY_CONDITION_CONTROLS
from .size_override_controls import SIZE_OVERRIDE_CONTROLS from .size_override_controls import SIZE_OVERRIDE_CONTROLS
from .spacing_controls import SPACING_CONTROLS from .spacing_controls import SPACING_CONTROLS
from .border_controls import BORDER_CONTROLS from .border_controls import BORDER_CONTROLS
@@ -11,4 +12,4 @@ from .border_controls import BORDER_CONTROLS
# et bindHoverTexts() dans play.html) reste fonctionnel pour les éléments qui # et bindHoverTexts() dans play.html) reste fonctionnel pour les éléments qui
# en avaient déjà un, seul le moyen de le RÉGLER depuis ce panneau disparaît # en avaient déjà un, seul le moyen de le RÉGLER depuis ce panneau disparaît
# pour l'instant. # pour l'instant.
UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL, c_scale()] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL, c_scale()] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + VISIBILITY_CONDITION_CONTROLS
@@ -0,0 +1,28 @@
from ..controls.c_text import c_text
from ..controls.c_select import c_select
from ...flow.constants import CONDITION_OPERATORS
_GROUP = "Condition de visibilité"
# Disponible sur TOUT widget (voir UNIVERSAL_CONTROLS) — masque l'élément en
# mode jouable (jamais dans l'éditeur, voir render_element_html.py) selon
# soit une variable globale (db/global_vars/), soit le champ d'un objet de
# données (convention "1 seule ligne = état de partie", déjà utilisée par la
# Jauge/Donnée liée). "visibilite_cond_valeur" se termine par "_valeur" :
# récupère automatiquement le sélecteur "Valeur fixe / Donnée d'un autre
# objet" déjà construit pour les filtres du Répéteur (voir screen_edit.html).
VISIBILITY_CONDITION_CONTROLS = [
c_select("visibilite_cond_mode", "Afficher cet élément seulement si...", "attr:_visibilite_cond_mode",
[("", "Toujours (pas de condition)"), ("variable", "Une variable globale..."), ("objet", "Un champ d'un objet...")],
default="", group=_GROUP),
c_select("visibilite_cond_variable", "Variable", "attr:_visibilite_cond_variable",
[("", "(sélectionner une variable)")], default="", group=_GROUP),
c_select("visibilite_cond_definition_id", "Objet", "attr:_visibilite_cond_definition_id",
[("", "(sélectionner un objet)")], default="", group=_GROUP),
c_select("visibilite_cond_champ", "Champ", "attr:_visibilite_cond_champ",
[("", "(sélectionner un objet d'abord)")], default="", group=_GROUP),
c_select("visibilite_cond_operateur", "...est", "attr:_visibilite_cond_operateur",
CONDITION_OPERATORS, default="egal", group=_GROUP),
c_text("visibilite_cond_valeur", "...cette valeur", "attr:_visibilite_cond_valeur",
placeholder="ex. 1", default="", group=_GROUP),
]
+10 -1
View File
@@ -36,12 +36,21 @@ def controls_with_values(slug, el):
# en même temps, mais un calcul dédié quand même pour rester correct si # en même temps, mais un calcul dédié quand même pour rester correct si
# ça change un jour. # ça change un jour.
data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id")) data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id"))
# Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) : elle
# aussi garde SON objet dans un attribut séparé (_visibilite_cond_
# definition_id), jamais partagé avec le Répéteur/la Jauge/Donnée liée.
visibility_cond_options = data_definition_options(slug, attributes.get("_visibilite_cond_definition_id"))
global_variables = db.list_global_variables(slug)
result = [] result = []
for control in meta.get("controls", []): for control in meta.get("controls", []):
control = dict(control) control = dict(control)
if control["key"] in ("definition_id", "data_definition_id"): if control["key"] in ("definition_id", "data_definition_id", "visibilite_cond_definition_id"):
defs = db.list_definitions(slug) defs = db.list_definitions(slug)
control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs] control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs]
if control["key"] == "visibilite_cond_champ":
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in visibility_cond_options["fields"]]
if control["key"] == "visibilite_cond_variable":
control["options"] = [("", "(sélectionner une variable)")] + [(v["name"], v["name"]) for v in global_variables]
if control["key"] == "template_type_id": if control["key"] == "template_type_id":
types = list_element_types(slug) types = list_element_types(slug)
control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types] control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types]
+13 -6
View File
@@ -362,12 +362,14 @@
if (!screenData || !screenDiv) return; if (!screenData || !screenDiv) return;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) { screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
if (!el.rendered_html) return; if (!el.rendered_html) return;
// "repeaterItem" (liste de données) et "jaugeBar" (2.1 — jauge liée // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
// à une donnée) sont les deux seuls types d'éléments dont le // à une donnée) et "visibilityGated" (condition de visibilité —
// contenu affiché dépend de la donnée elle-même plutôt que d'une // voir render_element_html.py) sont les types d'éléments dont le
// action "Modifier un élément" posée à la main dans le graphe — // contenu/l'affichage dépend de la donnée elle-même plutôt que
// eux seuls doivent être régénérés après un changement de donnée. // d'une action "Modifier un élément" posée à la main dans le
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1) return; // graphe — eux seuls doivent être régénérés après un changement
// de donnée ou de variable.
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return;
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner'); const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (wrapper) wrapper.innerHTML = el.rendered_html; if (wrapper) wrapper.innerHTML = el.rendered_html;
}); });
@@ -534,6 +536,11 @@
return fetch(runDataUrl, {method: 'POST'}) return fetch(runDataUrl, {method: 'POST'})
.then(r => r.json()) .then(r => r.json())
.then(() => refreshRuntimeData()); .then(() => refreshRuntimeData());
} else if (node.action_type === 'modifier_variable') {
const runVarUrl = "{{ url_for('flow_node_run_variable', slug=game.slug, node_id=999999) }}".replace('999999', node.id);
return fetch(runVarUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
} }
// 'rien' : aucune action volontairement. // 'rien' : aucune action volontairement.
return Promise.resolve(); return Promise.resolve();
+74 -2
View File
@@ -343,7 +343,11 @@
{% set ns.last_group = c.group %} {% set ns.last_group = c.group %}
{% set ns.started = True %} {% set ns.started = True %}
{% endif %} {% endif %}
<div class="field controlRow"> <div class="field controlRow{{ ' visCondRow' if c.key.startswith('visibilite_cond_') and c.key != 'visibilite_cond_mode' else '' }}"
{% if c.key == 'visibilite_cond_variable' %}data-vis-cond-scope="variable"
{% elif c.key in ('visibilite_cond_definition_id', 'visibilite_cond_champ') %}data-vis-cond-scope="objet"
{% elif c.key in ('visibilite_cond_operateur', 'visibilite_cond_valeur') %}data-vis-cond-scope="any"
{% endif %}>
{% if c.type in ('checkbox', 'toggle_token') %} {% if c.type in ('checkbox', 'toggle_token') %}
<div class="control"> <div class="control">
<label class="checkbox"> <label class="checkbox">
@@ -437,13 +441,16 @@
{% elif c.type in ('select', 'preset') %} {% elif c.type in ('select', 'preset') %}
<div class="control"> <div class="control">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select name="ctrl_{{ c.key }}" id="field-{{ c.key }}"> <select name="ctrl_{{ c.key }}" id="field-{{ c.key }}" {% if c.key == 'visibilite_cond_mode' %}onchange="toggleVisCondFields(this)"{% endif %}>
{% for opt in c.options %} {% for opt in c.options %}
<option value="{{ opt[0] }}" {{ 'selected' if c.value == opt[0] else '' }}>{{ opt[1] }}</option> <option value="{{ opt[0] }}" {{ 'selected' if c.value == opt[0] else '' }}>{{ opt[1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
</div> </div>
{% if c.key == 'visibilite_cond_variable' %}
<button type="button" class="button is-small is-fullwidth" style="margin-top:6px;" onclick="createVisCondVariable()">+ Créer une nouvelle variable</button>
{% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
@@ -618,6 +625,24 @@
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant"> <input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %} {% endif %}
</div> </div>
<div id="nf-action-variable-fields" style="display:none;">
{% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p>
{% else %}
<select id="nf-action-variable-name">
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select>
<select id="nf-action-variable-operation" style="margin-top:6px;">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
</div> </div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div> <div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
@@ -1448,7 +1473,10 @@ function initBuilderPanel() {
bindJaugeDefinitionSelect(); bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']); bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
bindDefinitionFieldSelects('field-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']); bindDefinitionFieldSelects('field-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']);
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
initFilterValuePickers(); initFilterValuePickers();
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
restoreTreeCollapsedState(); restoreTreeCollapsedState();
applySelectionHighlight(CURRENT_SELECTED_ID); applySelectionHighlight(CURRENT_SELECTED_ID);
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn'); restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
@@ -1497,6 +1525,41 @@ function bindJaugeDefinitionSelect() {
// généralisé pour s'appliquer à un nombre quelconque de listes "champ" // généralisé pour s'appliquer à un nombre quelconque de listes "champ"
// rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ, // rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ,
// ou data_filtre_champ + data_filtre2_champ). ---------- // ou data_filtre_champ + data_filtre2_champ). ----------
// ---------- Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) :
// n'affiche que les réglages pertinents pour le mode choisi (variable
// globale, ou champ d'un objet) — les deux autres restent en base (peu
// importe, seul le mode courant est lu par is_element_visible()) mais sont
// masqués pour ne pas embrouiller. ----------
function createVisCondVariable() {
var name = window.prompt("Nom de la nouvelle variable globale (ex. niveau_debloque) :");
if (!name || !name.trim()) return;
var body = new URLSearchParams({ name: name.trim(), type: 'texte', default_value: '' });
fetch("{{ url_for('create_global_var', slug=game.slug) }}", { method: 'POST', body: body })
.then(function (r) { return r.json(); })
.then(function (json) {
if (!json.ok) { window.alert(json.error || "Échec de la création."); return; }
var sel = document.getElementById('field-visibilite_cond_variable');
if (!sel) return;
if (!sel.querySelector('option[value="' + CSS.escape(json.name) + '"]')) {
var opt = document.createElement('option');
opt.value = json.name;
opt.textContent = json.name;
sel.appendChild(opt);
}
sel.value = json.name;
submitPropsForm();
});
}
function toggleVisCondFields(sel) {
var mode = sel.value;
document.querySelectorAll('.visCondRow').forEach(function (row) {
var scope = row.dataset.visCondScope;
var show = scope === 'any' ? !!mode : scope === mode;
row.style.display = show ? '' : 'none';
});
}
function bindDefinitionFieldSelects(defSelId, fieldSelIds) { function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
var defSel = document.getElementById(defSelId); var defSel = document.getElementById(defSelId);
if (!defSel) return; if (!defSel) return;
@@ -1769,6 +1832,9 @@ function nodeLabel(node) {
} else if (node.action_type === 'modifier_donnee') { } else if (node.action_type === 'modifier_donnee') {
base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation); base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value; if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'modifier_variable') {
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'attendre') { } else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)'; base += ' (' + (node.data_value || '0') + ' s)';
} }
@@ -2052,11 +2118,13 @@ function toggleFlowActionFields() {
var screenSel = document.getElementById('nf-action-target-screen'); var screenSel = document.getElementById('nf-action-target-screen');
var elementFields = document.getElementById('nf-action-element-fields'); var elementFields = document.getElementById('nf-action-element-fields');
var dataFields = document.getElementById('nf-action-data-fields'); var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields');
var waitFields = document.getElementById('nf-action-wait-fields'); var waitFields = document.getElementById('nf-action-wait-fields');
var isOnglet = typeSel.value === 'activer_onglet'; var isOnglet = typeSel.value === 'activer_onglet';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none'; document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
@@ -2185,6 +2253,10 @@ function submitNodeForm() {
payload.target_field = document.getElementById('nf-action-data-field').value; payload.target_field = document.getElementById('nf-action-data-field').value;
payload.data_operation = document.getElementById('nf-action-data-operation').value; payload.data_operation = document.getElementById('nf-action-data-operation').value;
payload.data_value = document.getElementById('nf-action-data-value').value; payload.data_value = document.getElementById('nf-action-data-value').value;
} else if (actionType === 'modifier_variable') {
payload.target_variable = document.getElementById('nf-action-variable-name').value;
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'attendre') { } else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value; payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} }