From c04bc0b926632d5eb2379573c592d1db4b1d99bd Mon Sep 17 00:00:00 2001 From: william Date: Tue, 25 Aug 2026 09:34:10 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20la=20condition=20de=20visibilit=C3=A9?= =?UTF-8?q?=20et=20les=20variables=20globales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 --- db/__init__.py | 8 ++ db/global_vars/__init__.py | 0 db/global_vars/create_global_variable.py | 27 +++++++ db/global_vars/delete_global_variable.py | 10 +++ db/global_vars/ensure_global_vars_schema.py | 23 ++++++ db/global_vars/get_global_variable.py | 16 ++++ db/global_vars/list_global_variables.py | 10 +++ .../update_global_variable_value.py | 18 +++++ routes/__init__.py | 2 +- routes/flow/__init__.py | 1 + routes/flow/flow_node_run_variable.py | 17 +++++ routes/global_vars/__init__.py | 1 + routes/global_vars/create_global_var.py | 19 +++++ routes/screens/screen_edit.py | 2 + screens/__init__.py | 1 + screens/data_actions/apply_variable_action.py | 53 +++++++++++++ screens/elements/list_elements.py | 13 +++- screens/flow/constants.py | 1 + screens/flow/ensure_flow_schema.py | 5 ++ screens/labels/action_type_labels.py | 1 + screens/payload/full_game_payload.py | 2 +- screens/rendering/apply_ctx.py | 5 ++ screens/rendering/filter_repeater_rows.py | 10 ++- screens/rendering/render_element_html.py | 30 +++++++- screens/rendering/visibility_condition.py | 44 +++++++++++ .../control_groups/universal_controls.py | 3 +- .../visibility_condition_controls.py | 28 +++++++ screens/widgets/controls_with_values.py | 11 ++- templates/play.html | 19 +++-- templates/screen_edit.html | 76 ++++++++++++++++++- 30 files changed, 436 insertions(+), 20 deletions(-) create mode 100644 db/global_vars/__init__.py create mode 100644 db/global_vars/create_global_variable.py create mode 100644 db/global_vars/delete_global_variable.py create mode 100644 db/global_vars/ensure_global_vars_schema.py create mode 100644 db/global_vars/get_global_variable.py create mode 100644 db/global_vars/list_global_variables.py create mode 100644 db/global_vars/update_global_variable_value.py create mode 100644 routes/flow/flow_node_run_variable.py create mode 100644 routes/global_vars/__init__.py create mode 100644 routes/global_vars/create_global_var.py create mode 100644 screens/data_actions/apply_variable_action.py create mode 100644 screens/rendering/visibility_condition.py create mode 100644 screens/widgets/control_groups/visibility_condition_controls.py diff --git a/db/__init__.py b/db/__init__.py index 80714494..a514da8e 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -52,6 +52,12 @@ from .rows.update_row_field import update_row_field from .rows.delete_row import delete_row 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__ = [ "PROJECTS_DIR", "FIELD_TYPES", "slugify", "table_name_for", "game_dir", "db_path", "connect", @@ -61,4 +67,6 @@ __all__ = [ "delete_definition", "list_rows", "relation_options", "insert_row", "get_row", "update_row", "update_row_field", "delete_row", "rows_referencing", + "list_global_variables", "get_global_variable", "create_global_variable", + "update_global_variable_value", "delete_global_variable", ] diff --git a/db/global_vars/__init__.py b/db/global_vars/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/db/global_vars/create_global_variable.py b/db/global_vars/create_global_variable.py new file mode 100644 index 00000000..29501df1 --- /dev/null +++ b/db/global_vars/create_global_variable.py @@ -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 diff --git a/db/global_vars/delete_global_variable.py b/db/global_vars/delete_global_variable.py new file mode 100644 index 00000000..2525bee3 --- /dev/null +++ b/db/global_vars/delete_global_variable.py @@ -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() diff --git a/db/global_vars/ensure_global_vars_schema.py b/db/global_vars/ensure_global_vars_schema.py new file mode 100644 index 00000000..c9a01899 --- /dev/null +++ b/db/global_vars/ensure_global_vars_schema.py @@ -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() diff --git a/db/global_vars/get_global_variable.py b/db/global_vars/get_global_variable.py new file mode 100644 index 00000000..e0102a70 --- /dev/null +++ b/db/global_vars/get_global_variable.py @@ -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 diff --git a/db/global_vars/list_global_variables.py b/db/global_vars/list_global_variables.py new file mode 100644 index 00000000..0bffbe63 --- /dev/null +++ b/db/global_vars/list_global_variables.py @@ -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] diff --git a/db/global_vars/update_global_variable_value.py b/db/global_vars/update_global_variable_value.py new file mode 100644 index 00000000..a6d85ff3 --- /dev/null +++ b/db/global_vars/update_global_variable_value.py @@ -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 diff --git a/routes/__init__.py b/routes/__init__.py index a82f793e..a4b6f4b4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/flow/__init__.py b/routes/flow/__init__.py index 2524b08e..d28a34e3 100644 --- a/routes/flow/__init__.py +++ b/routes/flow/__init__.py @@ -5,5 +5,6 @@ from . import ( flow_edge_add, flow_edge_delete, flow_node_run_data, + flow_node_run_variable, runtime_payload, ) diff --git a/routes/flow/flow_node_run_variable.py b/routes/flow/flow_node_run_variable.py new file mode 100644 index 00000000..ba9dedf2 --- /dev/null +++ b/routes/flow/flow_node_run_variable.py @@ -0,0 +1,17 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//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}) diff --git a/routes/global_vars/__init__.py b/routes/global_vars/__init__.py new file mode 100644 index 00000000..439cf84d --- /dev/null +++ b/routes/global_vars/__init__.py @@ -0,0 +1 @@ +from .create_global_var import create_global_var diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py new file mode 100644 index 00000000..13b4c8f9 --- /dev/null +++ b/routes/global_vars/create_global_var.py @@ -0,0 +1,19 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//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}) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index ed2ea7a9..e4bbb426 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -38,6 +38,7 @@ def screen_edit(slug, screen_id): 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} animation_clips = screens.list_animation_clips(slug, screen_id) + global_variables = db.list_global_variables(slug) return render_template( "screen_edit.html", @@ -74,4 +75,5 @@ def screen_edit(slug, screen_id): animate_css_catalog=screens.ANIMATE_CSS_CATALOG, animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), fontawesome_icons=screens.FONTAWESOME_ICONS, + global_variables=global_variables, ) diff --git a/screens/__init__.py b/screens/__init__.py index dd3ed5d9..888a4205 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -107,6 +107,7 @@ from .flow.add_flow_edge import add_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_variable_action import apply_variable_action from .animations.ensure_animation_schema import ensure_animation_schema from .animations.list_animation_clips import list_animation_clips diff --git a/screens/data_actions/apply_variable_action.py b/screens/data_actions/apply_variable_action.py new file mode 100644 index 00000000..f3d37704 --- /dev/null +++ b/screens/data_actions/apply_variable_action.py @@ -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) diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py index 11471e5c..69b17a0f 100644 --- a/screens/elements/list_elements.py +++ b/screens/elements/list_elements.py @@ -5,14 +5,20 @@ import db 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 l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls 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 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 - 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) rows = conn.execute( "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: if d.get("parent_id"): children_map.setdefault(d["parent_id"], []).append(d) + play_ctx = {"_forge_play_mode": True} if enforce_visibility else None for d in result: 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 diff --git a/screens/flow/constants.py b/screens/flow/constants.py index c06645d3..7fa3b03d 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -53,4 +53,5 @@ FLOW_NODE_FIELDS = { "cond_clauses", "cond_combinator", "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_variable", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index f0347d37..a796fe91 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -51,5 +51,10 @@ def ensure_flow_schema(slug): conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT") if "cond_combinator" not in cols: 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.close() diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index 28e02ae9..cd0f65ef 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -4,6 +4,7 @@ ACTION_TYPE_LABELS = { "aller_a": "Aller à un écran précis", "modifier_element": "Modifier un élément", "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", "activer_onglet": "Afficher cet élément, masquer tous ses frères", "attendre": "Attendre quelques secondes avant de continuer", diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 5d90b790..9f4cc791 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -21,7 +21,7 @@ def full_game_payload(slug): flows = {} animations = {} 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}) flows[str(s["id"])] = { "nodes": list_flow_nodes(slug, s["id"]), diff --git a/screens/rendering/apply_ctx.py b/screens/rendering/apply_ctx.py index 8eb5e7fc..428b26a7 100644 --- a/screens/rendering/apply_ctx.py +++ b/screens/rendering/apply_ctx.py @@ -5,5 +5,10 @@ def _apply_ctx(text, ctx): if not ctx or not text: return text 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) return text diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index 6e4a186a..ea84a966 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -51,7 +51,15 @@ def _compare(actual, operator, expected, field_type): """Équivalent Python de compareValues() dans templates/play.html — les deux doivent rester alignées si on ajoute un opérateur.""" 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 return a != e if operator == "different" else a == e try: diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index c37ef22c..53976f33 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -16,6 +16,7 @@ from .render_overlay import _render_overlay from .render_onglets import _render_onglets from .render_icone import _render_icone 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): @@ -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 par défaut de cet élément (voir _style_string).""" 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'' + + def _mark(html_str): + return html_str + "" if has_visibility_condition else html_str + + bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw) if bound_ctx: ctx = {**(ctx or {}), **bound_ctx} 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) 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) 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"): items = [line.strip() for line in content.split("\n") if line.strip()] inner = "".join(f"
  • {html_lib.escape(item)}
  • " for item in items) + child_html - return f"<{tag} {_attr_string(attrs, style)}>{inner}" + return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}") safe_content = html_lib.escape(content).replace("\n", "
    ") if content else "" - return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}" + return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}") diff --git a/screens/rendering/visibility_condition.py b/screens/rendering/visibility_condition.py new file mode 100644 index 00000000..f89922b8 --- /dev/null +++ b/screens/rendering/visibility_condition.py @@ -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) diff --git a/screens/widgets/control_groups/universal_controls.py b/screens/widgets/control_groups/universal_controls.py index 2dcc8d68..9c26e5d8 100644 --- a/screens/widgets/control_groups/universal_controls.py +++ b/screens/widgets/control_groups/universal_controls.py @@ -1,5 +1,6 @@ from ..controls.c_scale import c_scale from .visibility_control import VISIBILITY_CONTROL +from .visibility_condition_controls import VISIBILITY_CONDITION_CONTROLS from .size_override_controls import SIZE_OVERRIDE_CONTROLS from .spacing_controls import SPACING_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 # en avaient déjà un, seul le moyen de le RÉGLER depuis ce panneau disparaît # 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 diff --git a/screens/widgets/control_groups/visibility_condition_controls.py b/screens/widgets/control_groups/visibility_condition_controls.py new file mode 100644 index 00000000..46a2786a --- /dev/null +++ b/screens/widgets/control_groups/visibility_condition_controls.py @@ -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), +] diff --git a/screens/widgets/controls_with_values.py b/screens/widgets/controls_with_values.py index f6fce0f5..9cb7b8e2 100644 --- a/screens/widgets/controls_with_values.py +++ b/screens/widgets/controls_with_values.py @@ -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 # ça change un jour. 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 = [] for control in meta.get("controls", []): 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) 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": 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] diff --git a/templates/play.html b/templates/play.html index d0d5c75a..2ec358f7 100644 --- a/templates/play.html +++ b/templates/play.html @@ -362,12 +362,14 @@ if (!screenData || !screenDiv) return; screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) { if (!el.rendered_html) return; - // "repeaterItem" (liste de données) et "jaugeBar" (2.1 — jauge liée - // à une donnée) sont les deux seuls types d'éléments dont le - // contenu affiché dépend de la donnée elle-même plutôt que d'une - // action "Modifier un élément" posée à la main dans le graphe — - // eux seuls doivent être régénérés après un changement de donnée. - if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1) return; + // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée + // à une donnée) et "visibilityGated" (condition de visibilité — + // voir render_element_html.py) sont les types d'éléments dont le + // contenu/l'affichage dépend de la donnée elle-même plutôt que + // d'une action "Modifier un élément" posée à la main dans le + // 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'); if (wrapper) wrapper.innerHTML = el.rendered_html; }); @@ -534,6 +536,11 @@ return fetch(runDataUrl, {method: 'POST'}) .then(r => r.json()) .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. return Promise.resolve(); diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 08160754..b6bbf6e2 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -343,7 +343,11 @@ {% set ns.last_group = c.group %} {% set ns.started = True %} {% endif %} -
    +
    {% if c.type in ('checkbox', 'toggle_token') %}
    @@ -618,6 +625,24 @@ {% endif %}
    + +
    @@ -1448,7 +1473,10 @@ function initBuilderPanel() { bindJaugeDefinitionSelect(); 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-visibilite_cond_definition_id', ['field-visibilite_cond_champ']); initFilterValuePickers(); + var visCondModeSel = document.getElementById('field-visibilite_cond_mode'); + if (visCondModeSel) toggleVisCondFields(visCondModeSel); restoreTreeCollapsedState(); applySelectionHighlight(CURRENT_SELECTED_ID); restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn'); @@ -1497,6 +1525,41 @@ function bindJaugeDefinitionSelect() { // 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, // 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) { var defSel = document.getElementById(defSelId); if (!defSel) return; @@ -1769,6 +1832,9 @@ function nodeLabel(node) { } else if (node.action_type === 'modifier_donnee') { base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation); 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') { base += ' (' + (node.data_value || '0') + ' s)'; } @@ -2052,11 +2118,13 @@ function toggleFlowActionFields() { var screenSel = document.getElementById('nf-action-target-screen'); var elementFields = document.getElementById('nf-action-element-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 isOnglet = typeSel.value === 'activer_onglet'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; + variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none'; 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.data_operation = document.getElementById('nf-action-data-operation').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') { payload.data_value = document.getElementById('nf-action-wait-seconds').value; }