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:
co-authored by
Claude Sonnet 5
parent
3c6d07cb3a
commit
c04bc0b926
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'<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:
|
||||
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"<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 ""
|
||||
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}>")
|
||||
|
||||
@@ -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 .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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user