Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées.
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
def _apply_ctx(text, ctx):
|
||||
"""À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par
|
||||
la valeur de ce champ pour la ligne en cours — appliqué au contenu ET
|
||||
aux attributs de l'élément et de tous ses descendants."""
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
|
||||
def _attr_string(attrs, extra_style):
|
||||
parts = [f'style="{html_lib.escape(extra_style)}"']
|
||||
for k, v in attrs.items():
|
||||
if v in (None, "", False):
|
||||
continue
|
||||
parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"')
|
||||
return " ".join(parts)
|
||||
@@ -1,2 +0,0 @@
|
||||
def _auto_id(el):
|
||||
return f"elt-{el['id']}"
|
||||
@@ -1,199 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
|
||||
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
|
||||
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
|
||||
# convention de syntaxe que les {{champ}} déjà utilisés ailleurs (contenu
|
||||
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
|
||||
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
|
||||
|
||||
# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/)
|
||||
# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans
|
||||
# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend
|
||||
# toujours un point ("Objet.champ"). Le 2e groupe capture un CHEMIN
|
||||
# optionnel à l'intérieur de la variable ({{$perso.nom}}, {{$scores[0]}},
|
||||
# chaînable : {{$perso.arme.degats}}) — utile seulement pour une variable
|
||||
# de type "objet"/"tableau" (voir _resolve_variable_path ci-dessous), sans
|
||||
# effet sur une variable scalaire (chemin vide dans ce cas).
|
||||
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}\[\]]+)((?:\.[^.{}\[\]]+|\[\d+\])*)\s*\}\}$")
|
||||
|
||||
# Un segment de chemin est soit ".champ" (clé d'objet) soit "[index]"
|
||||
# (élément de tableau, 0 = premier) — voir _VAR_REF_PATTERN ci-dessus et le
|
||||
# contrôle "Chemin dans la variable" de la Condition de visibilité
|
||||
# (visibility_condition_controls.py).
|
||||
_PATH_SEGMENT_RE = re.compile(r"\.([^.\[\]]+)|\[(\d+)\]")
|
||||
|
||||
|
||||
def _resolve_variable_path(raw_value, path):
|
||||
"""Navigue dans une valeur JSON (chaîne stockée en base, colonne TEXT)
|
||||
selon un chemin ".champ"/"[index]" chaînable. Ne lève jamais : chemin
|
||||
vide -> valeur brute inchangée (utile pour une variable scalaire, où le
|
||||
chemin est toujours vide) ; JSON invalide, clé/index absent ou valeur
|
||||
intermédiaire pas navigable -> None — même convention que le reste de
|
||||
ce fichier (objet/variable introuvable -> ne matche rien plutôt que
|
||||
planter)."""
|
||||
if not path:
|
||||
return raw_value
|
||||
try:
|
||||
current = json.loads(raw_value) if raw_value else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for m in _PATH_SEGMENT_RE.finditer(path):
|
||||
key, index = m.group(1), m.group(2)
|
||||
try:
|
||||
current = current[key] if key is not None else current[int(index)]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _field_column(definition, field_name):
|
||||
"""Nom de la vraie colonne SQL d'un champ — un champ "relation" est
|
||||
stocké dans une colonne "<champ>_id" (voir create_definition.py), jamais
|
||||
sous son nom "propre" : sans ce cas particulier, filtrer ou comparer sur
|
||||
un champ relation ne trouvait jamais la bonne colonne et ne matchait
|
||||
donc jamais rien."""
|
||||
col = db.slugify(field_name).replace("-", "_")
|
||||
field = next((f for f in definition["fields"] if f["name"] == field_name), None)
|
||||
if field and field["type"] == "relation":
|
||||
col += "_id"
|
||||
return col
|
||||
|
||||
|
||||
def _resolve_filter_value(slug, raw_value, player_id=db.PLAYER_SHARED):
|
||||
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
|
||||
ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par
|
||||
convention, un objet utilisé comme "état de partie" — niveau courant,
|
||||
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place
|
||||
par des actions "Modifier une donnée" plutôt que d'en créer une
|
||||
nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
|
||||
lire la valeur ACTUELLE de cette variable globale (voir
|
||||
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe).
|
||||
`player_id` : état par joueur (Phase 1) — quel joueur consulte, pour
|
||||
qu'un objet/une variable per_player renvoie SA valeur (voir
|
||||
resolve_player_key.py/resolve_row_player_key.py, appelés en interne par
|
||||
get_global_variable/list_rows)."""
|
||||
if not raw_value:
|
||||
return raw_value
|
||||
stripped = raw_value.strip()
|
||||
|
||||
var_match = _VAR_REF_PATTERN.match(stripped)
|
||||
if var_match:
|
||||
variable = db.get_global_variable(slug, var_match.group(1).strip(), player_id)
|
||||
if not variable:
|
||||
return None # variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return _resolve_variable_path(variable.get("value"), var_match.group(2))
|
||||
|
||||
m = _REF_PATTERN.match(stripped)
|
||||
if not m:
|
||||
return raw_value
|
||||
object_name, field_name = m.group(1).strip(), m.group(2).strip()
|
||||
for d in db.list_definitions(slug):
|
||||
if d["name"] == object_name:
|
||||
full = db.get_definition(slug, d["id"])
|
||||
rows = db.list_rows(slug, full, player_id)
|
||||
if rows:
|
||||
return rows[0].get(_field_column(full, field_name))
|
||||
return None
|
||||
return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter
|
||||
|
||||
|
||||
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":
|
||||
# "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
|
||||
# "oui"/"non" est le vocabulaire affiché partout ailleurs dans
|
||||
# l'app pour un champ booléen (voir data_list.html, "Oui" si vrai
|
||||
# sinon "Non") — une valeur de comparaison fixe tapée "Oui" doit
|
||||
# donc être reconnue comme vraie ici aussi, pas seulement "1"/
|
||||
# "true"/"vrai" (sans quoi "Oui" retombait silencieusement à faux,
|
||||
# et un widget conditionné sur "est égal à Oui" ne s'affichait
|
||||
# jamais quand il aurait dû).
|
||||
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai", "oui") else 0
|
||||
return a != e if operator == "different" else a == e
|
||||
try:
|
||||
an, en = float(actual), float(expected)
|
||||
numeric = True
|
||||
except (TypeError, ValueError):
|
||||
numeric = False
|
||||
if numeric:
|
||||
if operator == "egal":
|
||||
return an == en
|
||||
if operator == "different":
|
||||
return an != en
|
||||
if operator == "superieur":
|
||||
return an > en
|
||||
if operator == "inferieur":
|
||||
return an < en
|
||||
if operator == "superieur_egal":
|
||||
return an >= en
|
||||
if operator == "inferieur_egal":
|
||||
return an <= en
|
||||
return False
|
||||
as_, es_ = ("" if actual is None else str(actual)), ("" if expected is None else str(expected))
|
||||
if operator == "egal":
|
||||
return as_ == es_
|
||||
if operator == "different":
|
||||
return as_ != es_
|
||||
if operator == "superieur":
|
||||
return as_ > es_
|
||||
if operator == "inferieur":
|
||||
return as_ < es_
|
||||
if operator == "superieur_egal":
|
||||
return as_ >= es_
|
||||
if operator == "inferieur_egal":
|
||||
return as_ <= es_
|
||||
return False
|
||||
|
||||
|
||||
def _clause_matches(slug, definition, row, clause, player_id=db.PLAYER_SHARED):
|
||||
field_name = (clause.get("champ") or "").strip()
|
||||
if not field_name:
|
||||
return True
|
||||
operator = clause.get("operateur") or "egal"
|
||||
expected = _resolve_filter_value(slug, clause.get("valeur") or "", player_id)
|
||||
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
|
||||
col = _field_column(definition, field_name)
|
||||
return _compare(row.get(col), operator, expected, field_type)
|
||||
|
||||
|
||||
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id=db.PLAYER_SHARED):
|
||||
"""Ne garde, parmi les lignes, que celles qui satisfont la liste de
|
||||
clauses données (un nombre quelconque), combinées entre elles par ET
|
||||
(toutes vraies) ou par OU (au moins une vraie) selon `combinator`
|
||||
("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses
|
||||
reconstruites depuis _filtre_champ/_filtre2_champ, voir
|
||||
filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/
|
||||
Titre (clauses illimitées, voir resolve_bound_row.py)."""
|
||||
if not clauses:
|
||||
return rows
|
||||
if combinator == "ou":
|
||||
return [r for r in rows if any(_clause_matches(slug, definition, r, c, player_id) for c in clauses)]
|
||||
return [r for r in rows if all(_clause_matches(slug, definition, r, c, player_id) for c in clauses)]
|
||||
|
||||
|
||||
def filter_repeater_rows(slug, definition, rows, attrs, player_id=db.PLAYER_SHARED):
|
||||
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
|
||||
au(x) filtre(s) optionnel(s) réglé(s) dans ses propriétés (_filtre_champ/
|
||||
_filtre_operateur/_filtre_valeur, ET, si renseigné, _filtre2_champ/
|
||||
_filtre2_operateur/_filtre2_valeur combiné en ET avec le premier) — voir
|
||||
1.2 dans la doc de cadrage Forge Engine ("affichage piloté par la donnée
|
||||
sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher,
|
||||
par exemple, que le niveau du parcours en cours (2 conditions : le bon
|
||||
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
|
||||
clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
|
||||
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id)
|
||||
@@ -1,25 +1,17 @@
|
||||
from ..screens_repo.list_screens import list_screens
|
||||
from ..elements.list_elements import list_elements
|
||||
from ..scenes.list_scene_objects import list_scene_objects
|
||||
from .personnage_data import resolve_personnage_data
|
||||
|
||||
|
||||
def list_used_forge_characters(slug):
|
||||
"""Ensemble des slugs SPRITE_LIBRARY (screens.SPRITE_LIBRARY)
|
||||
réellement référencés par CE jeu — tous écrans, écrans-modèles compris
|
||||
(même patron que full_game_payload.py : un personnage peut vivre dans
|
||||
un écran-modèle d'élément réutilisable). Utilisé par
|
||||
réellement référencés par CE jeu — tous écrans confondus. Utilisé par
|
||||
publish/build_scorm_package.py pour ne copier dans le .zip exporté que les
|
||||
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
|
||||
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
|
||||
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
|
||||
used = set()
|
||||
for s in list_screens(slug, include_templates=True):
|
||||
for el in list_elements(slug, s["id"]):
|
||||
if el.get("widget") == "personnage":
|
||||
data = resolve_personnage_data(el)
|
||||
if data.get("source") == "forge" and data.get("forge_character"):
|
||||
used.add(data["forge_character"])
|
||||
for o in list_scene_objects(slug, s["id"]):
|
||||
if o.get("kind") == "personnage":
|
||||
data = resolve_personnage_data(o)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
def _parent_flex_direction(parent_el):
|
||||
"""Sens de la disposition interne d'un conteneur/répéteur/groupe de
|
||||
champs (voir le réglage "Disposition interne"), ou None s'il est en
|
||||
empilement libre (pas de display:flex) — utilisé pour adapter la taille
|
||||
par défaut de ses enfants (voir _style_string)."""
|
||||
style = (parent_el or {}).get("style") or {}
|
||||
if style.get("display") != "flex":
|
||||
return None
|
||||
return style.get("flex-direction") or "row"
|
||||
@@ -1,19 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
input_type = "checkbox" if el.get("widget") == "case_a_cocher" else "radio"
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
attrs["type"] = input_type
|
||||
style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction)
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
# <label class="checkbox"/"radio"> entourant l'input EST la façon dont
|
||||
# Bulma stylise nativement une case à cocher/un bouton radio — pas de
|
||||
# wrapper supplémentaire nécessaire, la structure existante correspondait
|
||||
# déjà exactement à cette convention.
|
||||
return f'<label {_attr_string({"class": input_type}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>'
|
||||
@@ -1,13 +0,0 @@
|
||||
from .parent_flex_direction import _parent_flex_direction
|
||||
|
||||
|
||||
def _render_children(el, slug, children_map, ctx):
|
||||
"""Rendu récursif des éléments physiquement posés à l'intérieur d'un
|
||||
conteneur/répéteur/groupe de champs — chacun avec sa propre disposition,
|
||||
et le même contexte de ligne (ctx) que son parent s'il est dans un
|
||||
répéteur de données."""
|
||||
from .render_element_html import render_element_html
|
||||
|
||||
kids = (children_map or {}).get(el["id"], [])
|
||||
flex_dir = _parent_flex_direction(el)
|
||||
return "".join(render_element_html(c, slug, children_map, ctx, parent_flex_direction=flex_dir) for c in kids)
|
||||
@@ -1,220 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .render_children import _render_children
|
||||
from .render_table import _render_table
|
||||
from .render_select import _render_select
|
||||
from .render_fieldset import _render_fieldset
|
||||
from .render_checkbox_or_radio import _render_checkbox_or_radio
|
||||
from .render_repeater import _render_repeater
|
||||
from .render_jauge import _render_jauge
|
||||
from .render_overlay import _render_overlay
|
||||
from .render_onglets import _render_onglets
|
||||
from .render_icone import _render_icone
|
||||
from .render_personnage import _render_personnage
|
||||
from .resolve_bound_row import _resolve_bound_row_ctx
|
||||
from .visibility_condition import is_element_visible
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Construit le HTML réel (balise + attributs + style + contenu, y
|
||||
compris les éléments physiquement posés à l'intérieur pour un
|
||||
conteneur/répéteur/groupe de champs) — utilisé par l'éditeur (canevas)
|
||||
et le mode jouable. N'écrit jamais rien sur disque : uniquement du HTML
|
||||
généré à la volée à partir de ce qui est stocké en base. `slug` est
|
||||
nécessaire pour le Répéteur de données (il doit lire les données du jeu
|
||||
au moment de l'affichage). `ctx` porte les valeurs {{champ}} de la ligne
|
||||
en cours quand cet élément est descendant d'un répéteur. `parent_flex_direction`
|
||||
est la disposition interne (voir _parent_flex_direction) du conteneur qui
|
||||
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"))
|
||||
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, ctx):
|
||||
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
|
||||
|
||||
# "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu
|
||||
# ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du
|
||||
# rendu — potentiellement via une "Variable globale" en valeur de
|
||||
# comparaison (voir _resolve_filter_value). Comme "visibilityGated"
|
||||
# juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML 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" — sans lui, changer la variable choisie changeait bien QUELLE
|
||||
# ligne aurait dû s'afficher (recalculé correctement côté serveur à
|
||||
# chaque rendu), mais le client ne redemandait jamais le nouveau HTML de
|
||||
# CET élément précis : le texte affiché restait figé sur son ancienne
|
||||
# ligne jusqu'au prochain rechargement complet de la page.
|
||||
has_data_binding = bool(attrs_raw.get("_data_definition_id"))
|
||||
|
||||
def _mark(html_str):
|
||||
markers = ""
|
||||
if has_visibility_condition:
|
||||
markers += "<!--visibilityGated-->"
|
||||
if has_data_binding:
|
||||
markers += "<!--dataBound-->"
|
||||
return html_str + markers
|
||||
|
||||
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw, (ctx or {}).get("_forge_player_id", "__shared__"))
|
||||
if bound_ctx:
|
||||
ctx = {**(ctx or {}), **bound_ctx}
|
||||
# Chaque branche "special_render" passe elle aussi par _mark() : sans
|
||||
# ça, un widget de ce genre (Icône, Tableau, Superposition...) actuellement
|
||||
# VISIBLE sous condition ne portait aucun marqueur, et refreshRuntimeData()
|
||||
# (play.html) ne savait donc jamais qu'il fallait le réévaluer après une
|
||||
# action "Modifier une donnée/variable" — il restait figé dans son état
|
||||
# de l'affichage précédent (ex: une icône "enveloppe fermée" restée
|
||||
# visible après l'ouverture du mail, en même temps que "enveloppe
|
||||
# ouverte" qui apparaît, elle, correctement puisqu'un élément CACHÉ
|
||||
# porte toujours son marqueur via le placeholder ci-dessus).
|
||||
special = meta.get("special_render")
|
||||
if special == "table":
|
||||
return _mark(_render_table(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "select_options":
|
||||
return _mark(_render_select(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "fieldset_legend":
|
||||
return _mark(_render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "checkbox_label":
|
||||
return _mark(_render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "repeater":
|
||||
return _mark(_render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "jauge":
|
||||
return _mark(_render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "overlay":
|
||||
return _mark(_render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "onglets":
|
||||
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "icone":
|
||||
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "personnage":
|
||||
return _mark(_render_personnage(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
|
||||
tag = meta.get("tag")
|
||||
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
||||
niveau = (el.get("attributes") or {}).get("_niveau", "2")
|
||||
tag = f"h{niveau}"
|
||||
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
|
||||
# 3.1 (Confort) — interactions au survol : un texte de remplacement
|
||||
# optionnel, affiché à la place du contenu normal quand le joueur
|
||||
# survole cet élément à la souris (voir bindHoverTexts() dans
|
||||
# templates/play.html). Absent par défaut => aucun changement, donc
|
||||
# aucune régression sur les éléments déjà créés.
|
||||
hover_text = (el.get("attributes") or {}).get("_survol_texte")
|
||||
if hover_text:
|
||||
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
|
||||
|
||||
if meta.get("self_closing"):
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
|
||||
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
child_html, is_overlay_template = (
|
||||
_render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False)
|
||||
)
|
||||
if child_html is None:
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
elif is_overlay_template:
|
||||
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
|
||||
# générique "conteneur" (add_element.py, "default_widget") — utile
|
||||
# pour la plupart des modèles, mais QUAND le modèle entier n'est
|
||||
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
|
||||
# de ce widget (widgets/registry.py "conteneur") lui donnait un
|
||||
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
|
||||
# déposé, alors que la superposition à l'intérieur gère déjà
|
||||
# entièrement sa propre apparence et son propre masquage
|
||||
# (position:fixed plein écran, démarre masquée) — vécu comme "un
|
||||
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
|
||||
# (en réalité la boîte de dialogue existe bien, juste masquée comme
|
||||
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
|
||||
# propre apparence visuelle).
|
||||
#
|
||||
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
|
||||
# précédent le faisait, en renvoyant `child_html` directement) :
|
||||
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
|
||||
# précis (id=el["id"], distinct de l'id interne du modèle,
|
||||
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
|
||||
# CET exemplaire est, par ex., 77) — une action "Modifier un
|
||||
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
|
||||
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
|
||||
# partagé par tous les exemplaires, du modèle. La court-circuiter
|
||||
# entièrement faisait disparaître cet id du DOM, rendant
|
||||
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
|
||||
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
|
||||
# visibilité de la modale depuis la scène"). On garde donc la
|
||||
# balise (avec son id), mais SANS la classe "box" du widget
|
||||
# générique — juste une balise transparente, invisible tant
|
||||
# qu'elle ne porte ni fond ni bordure."""
|
||||
attrs.pop("class", None)
|
||||
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
|
||||
|
||||
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 _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
|
||||
|
||||
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
|
||||
|
||||
|
||||
def _render_element_type_children(el, slug, ctx):
|
||||
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
|
||||
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
|
||||
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
|
||||
copiés en base — exactement le même principe qu'un modèle de ligne de
|
||||
Répéteur (voir _render_repeater/_load_template_tree). Modifier
|
||||
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
||||
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
||||
retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a
|
||||
plus de modèle valide, pour que l'appelant retombe sur le rendu
|
||||
générique (d'éventuels enfants en base issus d'une version antérieure
|
||||
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
|
||||
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
|
||||
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
|
||||
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
|
||||
apparence visuelle)."""
|
||||
template_tree = _load_template_tree(slug, el["element_type_id"])
|
||||
if not template_tree:
|
||||
return None, False
|
||||
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
|
||||
if is_overlay_only:
|
||||
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
|
||||
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
|
||||
# masquée par défaut, exactement comme la superposition elle-même)
|
||||
# — la superposition INTERNE au modèle ne doit donc plus appliquer
|
||||
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
|
||||
# ailleurs (sinon double masquage : rendre l'enveloppe visible
|
||||
# depuis la scène qui l'accueille resterait sans aucun effet
|
||||
# visible tant que la superposition interne, elle, reste "Masqué"
|
||||
# dans le modèle). On retire donc "display" de sa copie de style
|
||||
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
|
||||
# display:flex de base (render_overlay.py) s'applique alors.
|
||||
overlay_root = dict(template_tree["top"][0])
|
||||
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
|
||||
template_tree["top"][0] = overlay_root
|
||||
html = "".join(
|
||||
render_element_html(c, slug, template_tree["children_map"], ctx)
|
||||
for c in template_tree["top"]
|
||||
)
|
||||
return html, is_overlay_only
|
||||
@@ -1,15 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .render_children import _render_children
|
||||
|
||||
|
||||
def _render_fieldset(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
content = _apply_ctx(el.get("content") or "", ctx) or "Groupe de champs"
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
return f'<fieldset {_attr_string(attrs, style)}><legend>{html_lib.escape(content)}</legend>{child_html}</fieldset>'
|
||||
@@ -1,24 +0,0 @@
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from ..widgets.fontawesome_icons import FONTAWESOME_ICONS
|
||||
|
||||
|
||||
def _render_icone(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Widget Icône — un SVG affiché en silhouette via "mask-image" (voir
|
||||
.icon-svg dans style.css), pas une police d'icônes : certains
|
||||
navigateurs/extensions bloquent le chargement de polices web tierces
|
||||
(protection anti-fingerprinting), ce qui rendait les icônes Font Awesome
|
||||
invisibles quelle que soit leur source — un SVG posé en masque n'est
|
||||
jamais concerné. Se colore via "color" (couleur du texte, déjà un
|
||||
réglage existant), se dimensionne via "font-size" (le masque fait 1em)."""
|
||||
raw_attrs = el.get("attributes") or {}
|
||||
icon_slug = raw_attrs.get("_icon_slug") or "star"
|
||||
if icon_slug not in FONTAWESOME_ICONS:
|
||||
icon_slug = "star" # attribut corrompu/vidé : jamais d'icône cassée
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
existing_class = attrs.pop("class", "")
|
||||
attrs["class"] = (existing_class + " icon-svg").strip()
|
||||
icon_url = f"/static/icons/{icon_slug}.svg"
|
||||
style = _style_string(el, f" --icon-url:url('{icon_url}');", parent_flex_direction)
|
||||
return f"<i {_attr_string(attrs, style)}></i>"
|
||||
@@ -1,164 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
import db
|
||||
|
||||
from .style_string import _style_string
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color):
|
||||
hex_color = (hex_color or "#5b8cff").lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
hex_color = "5b8cff"
|
||||
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _interpolate_color(low_hex, high_hex, ratio):
|
||||
"""Dégradé linéaire simple entre deux couleurs (rouge en bas, vert en
|
||||
haut par défaut, comme demandé dans le document d'interface de Boîte
|
||||
Mail) — ratio est déjà borné entre 0 et 1 par l'appelant."""
|
||||
lo, hi = _hex_to_rgb(low_hex), _hex_to_rgb(high_hex)
|
||||
mixed = tuple(round(lo[i] + (hi[i] - lo[i]) * ratio) for i in range(3))
|
||||
return "#{:02x}{:02x}{:02x}".format(*mixed)
|
||||
|
||||
|
||||
def _read_field_value(slug, definition_id, row_id, field_name, player_id=db.PLAYER_SHARED):
|
||||
"""Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de
|
||||
l'objet suivi — quand un objet contient plusieurs enregistrements (ex:
|
||||
Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est
|
||||
ce qui distingue lequel suivre. Repli sur la ligne la PLUS RÉCENTE si
|
||||
row_id est absent ou introuvable (jauges créées avant ce réglage, ou
|
||||
ligne supprimée depuis) — comportement historique, jamais de régression
|
||||
pour un objet à une seule ligne."""
|
||||
if not definition_id or not field_name or not slug:
|
||||
return None
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
if not definition:
|
||||
return None
|
||||
rows = db.list_rows(slug, definition, player_id)
|
||||
if not rows:
|
||||
return None
|
||||
row = None
|
||||
if row_id:
|
||||
row = next((r for r in rows if str(r["id"]) == str(row_id)), None)
|
||||
if row is None:
|
||||
row = rows[0]
|
||||
col = db.slugify(field_name).replace("-", "_")
|
||||
return row.get(col)
|
||||
|
||||
|
||||
def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Jauge/barre dont le remplissage (largeur en %) et la couleur (dégradé
|
||||
bas -> haut) se recalculent automatiquement à partir d'un champ
|
||||
numérique d'un objet de données — voir 2.1 dans la doc de cadrage Forge
|
||||
Engine ("binding live d'une donnée vers un style"). Recalculée à chaque
|
||||
régénération du HTML du jeu (chargement de /play, et après toute action
|
||||
"Modifier une donnée"), donc toujours à jour sans action supplémentaire
|
||||
à poser dans le graphe de logique."""
|
||||
attrs = el.get("attributes") or {}
|
||||
nom_position = attrs.get("_nom_position") or "dessus"
|
||||
nom_alignement = attrs.get("_nom_alignement") or "centre"
|
||||
nom_police = attrs.get("_nom_police") or "inherit"
|
||||
nom_taille = attrs.get("_nom_taille") or "12"
|
||||
nom_gras = attrs.get("_nom_gras") == "1"
|
||||
nom_italique = attrs.get("_nom_italique") == "1"
|
||||
# display:flex — le nom (optionnel) s'empile AU-DESSUS de la barre (mode
|
||||
# "dessus", flex-direction:column) ou se place à sa gauche (mode "cote",
|
||||
# flex-direction:row) plutôt que de se superposer dedans — voir name_html
|
||||
# plus bas. "justify-content:center" recentre le bloc nom+barre quand la
|
||||
# jauge se retrouve avec une hauteur supérieure à son contenu (ex. héritée
|
||||
# d'un conteneur parent qui l'étire) — sans ça le bloc restait collé en
|
||||
# haut de sa propre boîte, donnant une impression de centrage faussé dès
|
||||
# qu'un nom (donc plus de contenu) était ajouté. Le fond/bordure/overflow
|
||||
# vivent sur le wrapper de la barre (bar_wrap_style), pas ici, pour que le
|
||||
# nom reste en dehors de la zone rognée par "overflow:hidden".
|
||||
flex_dir = "row" if nom_position == "cote" else "column"
|
||||
align_items = "center" if nom_position == "cote" else "stretch"
|
||||
style = _style_string(
|
||||
el,
|
||||
f" display:flex; flex-direction:{flex_dir}; align-items:{align_items}; justify-content:center; gap:4px;",
|
||||
parent_flex_direction,
|
||||
)
|
||||
definition_id = attrs.get("_definition_id")
|
||||
row_id = attrs.get("_row_id")
|
||||
field_name = (attrs.get("_champ") or "").strip()
|
||||
field_name_nom = (attrs.get("_champ_nom") or "").strip()
|
||||
try:
|
||||
min_v = float(attrs.get("_min", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
min_v = 0.0
|
||||
try:
|
||||
max_v = float(attrs.get("_max", "100") or 100)
|
||||
except (TypeError, ValueError):
|
||||
max_v = 100.0
|
||||
couleur_basse = attrs.get("_couleur_basse") or "#e05252"
|
||||
couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
|
||||
afficher_valeur = attrs.get("_afficher_valeur") == "1"
|
||||
|
||||
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
|
||||
raw_value = _read_field_value(slug, definition_id, row_id, field_name, player_id) if field_name else None
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
value = min_v
|
||||
|
||||
span = (max_v - min_v) or 1
|
||||
ratio = max(0.0, min(1.0, (value - min_v) / span))
|
||||
percent = round(ratio * 100, 1)
|
||||
fill_color = _interpolate_color(couleur_basse, couleur_haute, ratio)
|
||||
|
||||
label_html = ""
|
||||
if afficher_valeur:
|
||||
label_html = (
|
||||
f'<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
f'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
f'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">{html_lib.escape(str(raw_value if raw_value is not None else "—"))}</div>'
|
||||
)
|
||||
|
||||
name_html = ""
|
||||
if field_name_nom:
|
||||
nom_value = _read_field_value(slug, definition_id, row_id, field_name_nom, player_id)
|
||||
if nom_value not in (None, ""):
|
||||
text_align = "left" if (nom_position == "dessus" and nom_alignement == "gauche") else "center"
|
||||
name_html = (
|
||||
f'<div style="flex:0 0 auto; font-family:{html_lib.escape(nom_police)}; '
|
||||
f'font-size:{html_lib.escape(str(nom_taille))}px; '
|
||||
f'font-weight:{"700" if nom_gras else "400"}; '
|
||||
f'font-style:{"italic" if nom_italique else "normal"}; '
|
||||
f'color:#e8eaf0; text-align:{text_align};">{html_lib.escape(str(nom_value))}</div>'
|
||||
)
|
||||
|
||||
# Vraie balise <progress> Bulma ("class=progress") plutôt qu'une barre
|
||||
# maison faite de <div> positionnés — la couleur dégradée bas/haut (voir
|
||||
# _interpolate_color ci-dessus) reste possible nativement : Bulma pilote
|
||||
# justement le remplissage via la variable CSS --bulma-progress-value-
|
||||
# background-color, qu'on peut fixer en ligne à la couleur calculée sans
|
||||
# sortir du système de classes de Bulma. La classe "jaugeBar" est
|
||||
# repérée côté client (play.html, refreshRuntimeData) pour savoir que
|
||||
# cette balise doit être régénérée après toute action "Modifier une
|
||||
# donnée" — sinon la jauge resterait figée sur sa valeur de départ après
|
||||
# un changement, exactement comme il fallait déjà repérer "repeaterItem"
|
||||
# pour les Répéteurs (voir 1.1/1.2).
|
||||
extra_classes = " ".join(v for k, v in attrs.items() if k.startswith("_class_") and v)
|
||||
bar_class = html_lib.escape((f"progress jaugeBar {extra_classes}").strip())
|
||||
bar = (
|
||||
f'<progress class="{bar_class}" value="{percent}" max="100" '
|
||||
f'style="--bulma-progress-value-background-color:{fill_color}; --bulma-progress-bar-background-color:#1c2130; '
|
||||
f'margin:0; border-radius:6px; transition:background-color .3s ease;">{percent}%</progress>'
|
||||
)
|
||||
# min-height (pas 0) : un élément posé DANS un conteneur a une hauteur
|
||||
# "auto" par défaut (voir _style_string) — "flex:1 1 auto" seul n'a
|
||||
# alors rien à répartir (le conteneur flex lui-même n'a pas de hauteur
|
||||
# définie tant que rien ne la contraint) et la barre s'effondrait à 0px,
|
||||
# ne laissant plus que le nom visible. Cette valeur plancher garde la
|
||||
# barre visible dans ce cas, tout en la laissant grandir avec flex:1 si
|
||||
# l'élément a une vraie hauteur (posé directement sur un écran, ou
|
||||
# hauteur fixée via "Position & taille").
|
||||
bar_wrap = (
|
||||
f'<div style="position:relative; display:flex; align-items:center; '
|
||||
f'flex:1 1 auto; min-height:14px;">{bar}{label_html}</div>'
|
||||
)
|
||||
return (
|
||||
f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">'
|
||||
f'{name_html}{bar_wrap}</div>'
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .parent_flex_direction import _parent_flex_direction
|
||||
from .auto_id import _auto_id
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_onglets(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Élément à onglets : chaque onglet est un vrai conteneur posé comme
|
||||
enfant (voir screens/elements/add_tab.py) — ça réutilise tel quel tout
|
||||
ce qui existe déjà pour un conteneur normal (ajouter du contenu dedans
|
||||
via "Ajouter DANS ce conteneur", le renommer pour changer le libellé
|
||||
affiché sur l'onglet, le supprimer via la poubelle standard) plutôt que
|
||||
d'inventer un stockage séparé pour les onglets. Un seul panneau visible
|
||||
à la fois, basculé par un petit script embarqué qui marche aussi bien
|
||||
dans l'éditeur qu'en jeu (voir forgeShowTab dans screen_edit.html et
|
||||
play.html)."""
|
||||
from .render_element_html import render_element_html
|
||||
|
||||
tabs = (children_map or {}).get(el["id"], [])
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
# Les variantes Bulma (alignement/style/taille... voir BULMA_TABS_CONTROLS)
|
||||
# calculées par _visible_attrs atterrissent dans "class" — c'est le
|
||||
# <div class="tabs"> qui doit les porter, pas ce wrapper de positionnement.
|
||||
tabs_extra_classes = attrs.pop("class", "")
|
||||
style = _style_string(el, " display:flex; flex-direction:column;", parent_flex_direction)
|
||||
group_id = _auto_id(el)
|
||||
|
||||
if not tabs:
|
||||
return (
|
||||
f'<div {_attr_string(attrs, style)}>'
|
||||
f'<em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>'
|
||||
)
|
||||
|
||||
flex_dir = _parent_flex_direction(el)
|
||||
bar_items = []
|
||||
panels = []
|
||||
for i, tab in enumerate(tabs):
|
||||
label = tab.get("name") or f"Onglet {i + 1}"
|
||||
active = i == 0
|
||||
tab_html = render_element_html(tab, slug, children_map, ctx, parent_flex_direction=flex_dir)
|
||||
target_id = f"tabpanel-{tab['id']}"
|
||||
# <li> Bulma natif ("tabs" > "ul" > "li", "is-active" sur le <li> —
|
||||
# pas le <a> — pour l'onglet en cours) : le <li> porte le clic ET les
|
||||
# data-attributes ciblés par forgeShowTab (voir screen_edit.html /
|
||||
# play.html), le <a> imbriqué n'est là que pour le rendu visuel Bulma.
|
||||
bar_items.append(
|
||||
f'<li class="{"is-active" if active else ""}" '
|
||||
f'data-tab-btn="{group_id}" data-tab-target="{target_id}" '
|
||||
f'onclick="forgeShowTab(this)"><a>{html_lib.escape(str(label))}</a></li>'
|
||||
)
|
||||
hidden_style = "" if active else ' style="display:none;"'
|
||||
panels.append(
|
||||
f'<div class="forgeTabPanel" id="{target_id}" data-tab-group="{group_id}"{hidden_style}>'
|
||||
f'{tab_html}</div>'
|
||||
)
|
||||
|
||||
tabs_class = html_lib.escape(("tabs " + tabs_extra_classes).strip())
|
||||
return (
|
||||
f'<div {_attr_string(attrs, style)}>'
|
||||
f'<div class="{tabs_class}"><ul>{"".join(bar_items)}</ul></div>'
|
||||
f'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">{"".join(panels)}</div>'
|
||||
f"</div>"
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .render_children import _render_children
|
||||
|
||||
|
||||
def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""3.4 (Confort) — overlay/modale réutilisable : une boîte de dialogue
|
||||
prête à l'emploi, par-dessus TOUT le reste de l'écran EN MODE JOUABLE
|
||||
UNIQUEMENT (`position:fixed; inset:0` + voile semi-transparent, ignore
|
||||
x/y/width/height) — fermeture manuelle uniquement (aucun
|
||||
clic-en-dehors-pour-fermer volontairement, conformément au manque
|
||||
documenté).
|
||||
|
||||
Dans l'ÉDITEUR (ctx sans "_forge_play_mode" — posé uniquement en mode
|
||||
jouable, voir list_elements.py), ce widget se comporte comme un
|
||||
CONTENEUR NORMAL : position/taille selon x/y/width/height comme
|
||||
n'importe quel widget, respect normal de son réglage "Visibilité"
|
||||
(masqué = invisible dans l'éditeur aussi, comme tout autre widget),
|
||||
AUCUN voile plein écran. Deux essais précédents corrigés à partir des
|
||||
retours utilisateur :
|
||||
- Fond assombri visible pendant l'édition ("je veux que rien ne soit
|
||||
assombri, l'assombrissement ne se fait que quand la scène est
|
||||
jouée") : le voile (background:rgba(...)) ne fait donc plus partie
|
||||
du style de base, il n'est ajouté qu'en mode jouable.
|
||||
- Visibilité forcée en permanence dans l'éditeur ("je veux avoir la
|
||||
main sur la visibilité, sinon elle s'affiche toujours sur la scène
|
||||
et court-circuite ma logique") : le forçage display:flex a donc été
|
||||
retiré — l'éditeur respecte de nouveau fidèlement le réglage
|
||||
"Visibilité" (masqué par défaut à la création, pour ne plus couvrir
|
||||
tout l'écran EN JEU dès qu'on la pose — sans plus aucun rapport avec
|
||||
l'éditeur, qui ne couvre plus jamais rien).
|
||||
|
||||
Ouverture/fermeture (en JEU) : PAS de mécanisme dédié — elle réutilise
|
||||
l'action existante "Modifier un élément → Visibilité" (masquer/rendre
|
||||
visible), exactement comme n'importe quel autre élément.
|
||||
|
||||
Classes Bulma ("modal is-active" / "box") posées en PLUS du style
|
||||
inline, jamais à sa place, et UNIQUEMENT en mode jouable (voir
|
||||
ci-dessus) : tout le positionnement/masquage critique (position:fixed,
|
||||
z-index, display) reste en inline, qui gagne toujours sur une règle de
|
||||
classe — si Bulma (chargé depuis un CDN, voir play.html) ne se charge
|
||||
pas (hors-ligne), la boîte de dialogue continue de fonctionner
|
||||
exactement pareil. Pas de ".modal-background" séparé : le voile
|
||||
semi-transparent est déjà posé en inline sur cette même balise
|
||||
(background:rgba(...)) — un second calque tout aussi transparent
|
||||
par-dessus n'ajouterait rien."""
|
||||
is_play_mode = bool(ctx and ctx.get("_forge_play_mode"))
|
||||
attrs_raw = el.get("attributes") or {}
|
||||
box_color = attrs_raw.get("_couleur_boite") or "#1f2430"
|
||||
radius = attrs_raw.get("_arrondi") or "12"
|
||||
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
if is_play_mode:
|
||||
base_overlay_style = (
|
||||
"position:fixed; inset:0; z-index:9999; display:flex; "
|
||||
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);"
|
||||
)
|
||||
style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction)
|
||||
wrapper_class = "modal is-active"
|
||||
else:
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction)
|
||||
wrapper_class = ""
|
||||
|
||||
attr_parts = [f'style="{html_lib.escape(style)}"']
|
||||
for k, v in attrs.items():
|
||||
if v in (None, "", False):
|
||||
continue
|
||||
attr_parts.append(
|
||||
html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"'
|
||||
)
|
||||
|
||||
box_style = (
|
||||
f"background:{html_lib.escape(str(box_color))}; border-radius:{html_lib.escape(str(radius))}px; "
|
||||
# max-width en min(...) plutôt qu'un simple pourcentage : sur un
|
||||
# écran de jeu large (desktop), "90%" seul donnait une boîte étirée
|
||||
# bord à bord peu lisible comme dialogue — 560px reste une largeur
|
||||
# de boîte de dialogue confortable, tout en retombant sur 90% sur un
|
||||
# écran de jeu étroit (mobile/portrait) pour ne jamais déborder.
|
||||
# color:#e8eaf0 : la classe Bulma ".box" (ajoutée ci-dessous) impose
|
||||
# elle-même une couleur de texte SOMBRE (pensée pour un fond blanc
|
||||
# par défaut) — comme aucun widget ne fige de couleur de texte à sa
|
||||
# création (default_style_for_widget.py), un titre/texte posé dans
|
||||
# la boîte SANS couleur personnalisée héritait de ce gris sombre
|
||||
# imposé par Bulma, invisible sur le fond sombre par défaut de cette
|
||||
# boîte de dialogue (texte "présent mais invisible", pas de bug côté
|
||||
# utilisateur). On redonne donc ici une couleur claire par défaut,
|
||||
# que Bulma ne peut plus écraser (élément le plus proche gagne) —
|
||||
# un texte/titre qui personnalise sa propre couleur reste bien sûr
|
||||
# prioritaire sur celle-ci.
|
||||
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;"
|
||||
)
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
wrapper_class_attr = f'class="{wrapper_class}" ' if wrapper_class else ""
|
||||
return (
|
||||
f'<div {wrapper_class_attr}{" ".join(attr_parts)}>'
|
||||
f'<div class="box forgeOverlayBox" style="{html_lib.escape(box_style)}">{child_html}</div>'
|
||||
f"</div>"
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .personnage_data import resolve_personnage_idle_frame
|
||||
|
||||
|
||||
def _render_personnage(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Widget Personnage — un <img> qui affiche sa pose "idle" dès le rendu
|
||||
SERVEUR (aperçu correct même sans JS), animée ensuite en boucle par
|
||||
static/js/screen_edit/personnage-preview.js dans l'éditeur, ou par une
|
||||
action de flow/un clip de Timeline en mode jouable (voir
|
||||
runSpriteAnimation() dans static/js/play/actions.js — moteur inchangé,
|
||||
seul l'auteuring dans l'éditeur diffère). data-personnage="1" marque
|
||||
cet élément pour la prévisualisation animée de l'éditeur."""
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
attrs["data-personnage"] = "1"
|
||||
idle_frame = resolve_personnage_idle_frame(el)
|
||||
if idle_frame:
|
||||
attrs["src"] = idle_frame
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
return f"<img {_attr_string(attrs, style)}>"
|
||||
@@ -1,79 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
import db
|
||||
|
||||
from .style_string import _style_string
|
||||
from .auto_id import _auto_id
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .filter_repeater_rows import filter_repeater_rows, _field_column
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
|
||||
|
||||
def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_direction=None):
|
||||
from .render_element_html import render_element_html
|
||||
|
||||
style = _style_string(el, " overflow:auto;", parent_flex_direction, widget_key=el.get("widget"))
|
||||
attrs_raw = el.get("attributes") or {}
|
||||
definition_id = attrs_raw.get("_definition_id")
|
||||
# data-definition-id sur le Répéteur lui-même : lu côté client (play.html)
|
||||
# pour savoir, au clic sur une de ses lignes, à quel objet de données
|
||||
# appartient la ligne cliquée (voir déclencheur "clic sur une ligne de
|
||||
# Répéteur" et l'action "Ouvrir la ligne cliquée").
|
||||
definition_attr = f' data-definition-id="{html_lib.escape(str(definition_id))}"' if definition_id else ""
|
||||
template_type_id = attrs_raw.get("_template_type_id")
|
||||
template = _apply_ctx(el.get("content") or "", ctx)
|
||||
children = (children_map or {}).get(el["id"], [])
|
||||
if not definition_id or not slug:
|
||||
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>'
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
if not definition:
|
||||
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>'
|
||||
# (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois
|
||||
# qu'on sait que l'objet est valide.)
|
||||
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
|
||||
rows = db.list_rows(slug, definition, player_id)
|
||||
rows = filter_repeater_rows(slug, definition, rows, attrs_raw, player_id)
|
||||
field_cols = {f["name"]: _field_column(definition, f["name"]) for f in definition["fields"]}
|
||||
|
||||
# Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) :
|
||||
# on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié
|
||||
# en base), et on l'affiche avec les {{champ}} de cette ligne — voir
|
||||
# _load_template_tree pour le détail de cette astuce de rendu.
|
||||
template_tree = None
|
||||
if template_type_id:
|
||||
try:
|
||||
template_tree = _load_template_tree(slug, int(template_type_id))
|
||||
except (TypeError, ValueError):
|
||||
template_tree = None
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
row_ctx = dict(ctx or {})
|
||||
row_ctx.update({fname: str(row.get(col, "") or "") for fname, col in field_cols.items()})
|
||||
if template_tree:
|
||||
inner = "".join(
|
||||
render_element_html(c, slug, template_tree["children_map"], row_ctx)
|
||||
for c in template_tree["top"]
|
||||
)
|
||||
elif children:
|
||||
inner = "".join(render_element_html(c, slug, children_map, row_ctx) for c in children)
|
||||
else:
|
||||
line = template
|
||||
for fname, val in row_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 (même garde que
|
||||
# apply_ctx.py, qui gère le même ctx pour le chemin
|
||||
# générique).
|
||||
if fname == "_forge_play_mode":
|
||||
continue
|
||||
line = line.replace("{{" + fname + "}}", val)
|
||||
inner = html_lib.escape(line)
|
||||
# data-row-id : identifiant STABLE de la vraie ligne de données
|
||||
# affichée (par opposition à data-element-id, qui vaut l'id du
|
||||
# modèle de ligne et se répète à l'identique sur chaque ligne) —
|
||||
# c'est ce qui permet au clic de savoir "quel mail" a été ouvert.
|
||||
items.append(f'<div class="repeaterItem" data-row-id="{row["id"]}">{inner}</div>')
|
||||
empty_msg = "Aucune ligne ne correspond au filtre réglé." if attrs_raw.get("_filtre_champ") else "Aucune donnée dans cet objet pour l'instant."
|
||||
inner_all = "".join(items) if items else f"<em>{empty_msg}</em>"
|
||||
return f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}"{definition_attr} style="{html_lib.escape(style)}">{inner_all}</div>'
|
||||
@@ -1,31 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
# Bulma stylise un <select> via la classe "select" posée sur un <div>
|
||||
# QUI L'ENTOURE (le petit triangle est un ::after de ce wrapper) — la
|
||||
# poser directement sur la balise <select> elle-même n'aurait aucun
|
||||
# effet. Le wrapper porte donc l'id/positionnement habituel de
|
||||
# l'élément (comme tous les autres widgets), le <select> reste à
|
||||
# l'intérieur avec ses propres attributs de formulaire (name, required).
|
||||
outer_attrs = _visible_attrs(el, meta, ctx)
|
||||
# Les variantes Bulma (couleur/taille/arrondi... voir BULMA_FIELD_CONTROLS)
|
||||
# calculées par _visible_attrs atterrissent normalement dans "class" —
|
||||
# ici c'est le <div class="select"> qui doit les porter, pas le <select>
|
||||
# ni le wrapper de positionnement, donc on les redirige à la main.
|
||||
bulma_classes = outer_attrs.get("class", "")
|
||||
select_attrs = {k: v for k, v in outer_attrs.items() if k not in ("id", "class", "data-element-id", "data-anim-target")}
|
||||
wrapper_attrs = {k: v for k, v in outer_attrs.items() if k in ("id", "data-element-id", "data-anim-target")}
|
||||
style = _style_string(el, " display:flex;", parent_flex_direction)
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
options = [o.strip() for o in content.split("\n") if o.strip()]
|
||||
opts_html = "".join(f'<option value="{html_lib.escape(o)}">{html_lib.escape(o)}</option>' for o in options)
|
||||
select_html = f'<select {_attr_string(select_attrs, "")}>{opts_html}</select>'
|
||||
select_wrap_class = html_lib.escape(("select is-fullwidth " + bulma_classes).strip())
|
||||
return f'<div {_attr_string(wrapper_attrs, style)}><div class="{select_wrap_class}">{select_html}</div></div>'
|
||||
@@ -1,21 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on")
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
style = _style_string(el, "", parent_flex_direction)
|
||||
rows = [r for r in content.split("\n") if r.strip()]
|
||||
html_rows = []
|
||||
for i, r in enumerate(rows):
|
||||
cells = r.split(";")
|
||||
cell_tag = "th" if (header_row and i == 0) else "td"
|
||||
cells_html = "".join(f"<{cell_tag}>{html_lib.escape(c.strip())}</{cell_tag}>" for c in cells)
|
||||
html_rows.append(f"<tr>{cells_html}</tr>")
|
||||
return f'<table {_attr_string(attrs, style)}>{"".join(html_rows)}</table>'
|
||||
@@ -1,36 +0,0 @@
|
||||
import db
|
||||
|
||||
from .filter_repeater_rows import filter_rows_by_clauses, _field_column
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
|
||||
def _resolve_bound_row_ctx(slug, attrs, player_id=db.PLAYER_SHARED):
|
||||
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
|
||||
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
|
||||
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions
|
||||
réglées (un nombre quelconque, combinées par ET/OU — attr:
|
||||
_data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien
|
||||
format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments
|
||||
créés avant l'ajout des clauses illimitées) — même moteur que le
|
||||
filtre du Répéteur de données, voir filter_repeater_rows.py — et
|
||||
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
|
||||
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
|
||||
valeur calculée (ex: l'objectif du niveau en cours) sans avoir besoin de
|
||||
poser un Répéteur, qui affiche lui plusieurs lignes. Renvoie None si
|
||||
rien n'est lié ou si aucune ligne ne correspond."""
|
||||
definition_id = attrs.get("_data_definition_id")
|
||||
if not definition_id or not slug:
|
||||
return None
|
||||
try:
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not definition:
|
||||
return None
|
||||
rows = db.list_rows(slug, definition, player_id)
|
||||
clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre")
|
||||
rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id)
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
return {f["name"]: str(row.get(_field_column(definition, f["name"]), "") or "") for f in definition["fields"]}
|
||||
@@ -1,80 +0,0 @@
|
||||
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
|
||||
|
||||
def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None):
|
||||
style = dict(el.get("style") or {})
|
||||
if widget_key in LAYOUT_CAPABLE_WIDGETS and "display" not in style:
|
||||
# "Empilement libre" (valeur par défaut de "Disposition interne")
|
||||
# doit quand même permettre l'espacement (gap) entre les enfants —
|
||||
# or le CSS "gap" n'a aucun effet sur un display bloc classique, il
|
||||
# lui faut un conteneur flex/grid. Tant qu'aucune disposition n'a
|
||||
# été choisie explicitement, on bascule donc ce widget sur un flex
|
||||
# en colonne : visuellement identique à un simple empilement, mais
|
||||
# l'espacement fonctionne dès la création, sans avoir à changer la
|
||||
# disposition interne au préalable.
|
||||
style["display"] = "flex"
|
||||
style.setdefault("flex-direction", "column")
|
||||
is_child = bool(el.get("parent_id"))
|
||||
if is_child:
|
||||
# Élément physiquement posé à l'intérieur d'un conteneur/répéteur/
|
||||
# groupe de champs : une hauteur fixée à 100% se calcule contre la
|
||||
# hauteur du parent et écraserait/déborderait les autres enfants
|
||||
# (le conteneur coupe alors ce qui dépasse -> l'enfant "disparaît").
|
||||
# La hauteur s'adapte donc à son contenu, comme dans une vraie page.
|
||||
# Largeur : dans une disposition en LIGNE (flex-direction row), la
|
||||
# largeur est l'axe principal — si chaque enfant réclame 100% de
|
||||
# cette largeur, ils entrent en collision (l'algorithme flex les
|
||||
# comprime alors tous, jusqu'à les faire quasi disparaître). Dans les
|
||||
# autres cas (empilement libre, ou disposition en COLONNE où la
|
||||
# largeur est l'axe transversal), 100% reste le bon défaut.
|
||||
width_default = "auto" if parent_flex_direction in ("row", "row-reverse") else "100%"
|
||||
base_style = f"width:{width_default}; height:auto; box-sizing:border-box; margin:0;" + extra_base
|
||||
else:
|
||||
base_style = "width:100%; height:100%; box-sizing:border-box; margin:0;" + extra_base
|
||||
|
||||
has_size_override = False
|
||||
|
||||
def _emit(k, v):
|
||||
nonlocal has_size_override
|
||||
if v in (None, ""):
|
||||
return False
|
||||
if isinstance(v, (dict, list)):
|
||||
# Ne devrait jamais arriver (chaque contrôle composite — ex.
|
||||
# "shadow", voir c_shadow.py — est censé combiner ses sous-
|
||||
# valeurs en une seule chaîne CSS avant d'être stocké), mais
|
||||
# sert de filet de sécurité : un dict/une liste écrits tels
|
||||
# quels ici donneraient une valeur CSS invalide (repr Python)
|
||||
# qui cassait TOUT le style de l'élément (bug déjà corrigé côté
|
||||
# écriture — voir default_style_for_widget.py — pour des
|
||||
# éléments déjà créés avant ce correctif).
|
||||
return False
|
||||
if k == "transform" and not is_child:
|
||||
# Un élément posé directement sur un écran a déjà ce "transform"
|
||||
# posé sur son cadre .canvasElementInner / .playElementInner
|
||||
# (voir filters/element_style_filter.py:_element_transform_style)
|
||||
# — l'écrire ici aussi (sur la balise intérieure, à l'intérieur
|
||||
# d'un cadre qui reste fixe) donnait l'impression que l'échelle/
|
||||
# l'animation restait "coincée à l'intérieur" au lieu de
|
||||
# s'appliquer à l'élément entier. Un élément posé à l'intérieur
|
||||
# d'un conteneur/répéteur/groupe de champs n'a pas ce cadre
|
||||
# séparé (sa balise EST déjà tout l'élément) : pour lui,
|
||||
# "transform" reste ici.
|
||||
return False
|
||||
if k in ("width", "height") and str(v).strip() in ("0px", "0"):
|
||||
# 0 = réglage "Taille dans le conteneur" laissé à sa valeur par
|
||||
# défaut (voir SIZE_OVERRIDE_CONTROLS) : ne pas l'écrire, pour
|
||||
# garder le comportement automatique de base_style ci-dessus.
|
||||
return False
|
||||
if k in ("width", "height"):
|
||||
has_size_override = True
|
||||
return True
|
||||
|
||||
user_style = "; ".join(f"{k}:{v}" for k, v in style.items() if _emit(k, v))
|
||||
if has_size_override:
|
||||
# Une taille fixe explicite (voir "Taille dans le conteneur") ne doit
|
||||
# pas être écrasée par la compression automatique d'une disposition
|
||||
# flex (flex-shrink vaut 1 par défaut sur tout enfant) — sinon un
|
||||
# élément à qui on a donné une largeur/hauteur précise peut quand
|
||||
# même se retrouver comprimé si ses voisins manquent de place.
|
||||
user_style = (user_style + "; " if user_style else "") + "flex-shrink:0"
|
||||
return base_style + (" " + user_style + ";" if user_style else "")
|
||||
@@ -1,69 +0,0 @@
|
||||
import db
|
||||
|
||||
from .filter_repeater_rows import _resolve_filter_value, _resolve_variable_path, _compare, _field_column
|
||||
|
||||
|
||||
def is_element_visible(slug, attrs, ctx=None):
|
||||
"""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. 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.
|
||||
|
||||
`ctx` porte les {{champ}} de la ligne en cours (voir render_repeater.py)
|
||||
quand cet élément est descendant d'un Répéteur de données — en mode
|
||||
"objet", si le champ réglé s'y trouve déjà, sa valeur est utilisée telle
|
||||
quelle plutôt que d'aller chercher la ligne la plus récente de l'objet
|
||||
en base : sans ça, TOUS les exemplaires d'un même modèle de ligne (ex.
|
||||
une "Mail card" répétée pour chaque e-mail) évalueraient la même ligne
|
||||
(la plus récente de l'objet) au lieu de chacun la sienne, et
|
||||
n'afficheraient donc jamais rien de cohérent. Hors Répéteur (mode
|
||||
"objet" sur un élément posé directement sur l'écran), le champ n'est
|
||||
pas dans ctx et on retombe sur la convention "1 seule ligne = état de
|
||||
partie" (déjà utilisée par la Jauge/Donnée liée) : la ligne la plus
|
||||
récente de l'objet choisi."""
|
||||
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
|
||||
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 "", player_id)
|
||||
|
||||
if mode == "variable":
|
||||
var_name = attrs.get("_visibilite_cond_variable")
|
||||
if not var_name:
|
||||
return True
|
||||
variable = db.get_global_variable(slug, var_name, player_id)
|
||||
if not variable:
|
||||
return True # variable supprimée depuis : ne bloque jamais l'affichage
|
||||
value = variable.get("value")
|
||||
# "Chemin dans la variable" (voir visibility_condition_controls.py)
|
||||
# : nécessaire pour comparer un CHAMP d'une variable "objet" ou un
|
||||
# ÉLÉMENT d'une variable "tableau", pas seulement la variable
|
||||
# entière — même syntaxe ".champ"/"[index]" que {{$var.chemin}}.
|
||||
path = attrs.get("_visibilite_cond_variable_chemin")
|
||||
if path:
|
||||
value = _resolve_variable_path(value, path)
|
||||
return _compare(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
|
||||
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
|
||||
|
||||
if ctx and field_name in ctx:
|
||||
return _compare(ctx.get(field_name), operator, expected, field_type)
|
||||
|
||||
rows = db.list_rows(slug, definition, player_id)
|
||||
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
|
||||
return _compare(row.get(_field_column(definition, field_name)), operator, expected, field_type)
|
||||
@@ -1,34 +0,0 @@
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _visible_attrs(el, meta, ctx=None):
|
||||
raw_attrs = el.get("attributes") or {}
|
||||
attrs = {
|
||||
k: (_apply_ctx(v, ctx) if isinstance(v, str) else v)
|
||||
for k, v in raw_attrs.items() if not k.startswith("_")
|
||||
}
|
||||
attrs.update(meta.get("fixed_attrs", {}))
|
||||
# Classes externes (ex. Bulma) réglées via un contrôle "class:xxx" — voir
|
||||
# save_element_controls.py — chacune mémorisée sous "_class_xxx" pour ne
|
||||
# pas s'écraser entre elles ; assemblées ici avec la classe de base du
|
||||
# widget (posée via fixed_attrs, ex. "button"/"box"/"title") pour former
|
||||
# l'attribut class final. Une classe personnalisée éventuellement tapée
|
||||
# à la main (peu probable ici, mais possible via fixed_attrs) est gardée
|
||||
# en tête.
|
||||
extra_classes = [v for k, v in raw_attrs.items() if k.startswith("_class_") and v]
|
||||
if extra_classes:
|
||||
base_class = attrs.get("class", "")
|
||||
attrs["class"] = " ".join(([base_class] if base_class else []) + extra_classes)
|
||||
attrs.setdefault("id", _auto_id(el))
|
||||
attrs["data-element-id"] = el["id"]
|
||||
if el.get("parent_id"):
|
||||
# Élément posé à l'intérieur d'un conteneur/répéteur/groupe de
|
||||
# champs : pas de cadre de positionnement séparé autour de lui (voir
|
||||
# element_style_filter.py) — sa propre balise EST déjà tout
|
||||
# l'élément, donc c'est elle que la timeline d'animation doit viser.
|
||||
# Un élément posé directement sur un écran, lui, porte ce marqueur
|
||||
# sur son cadre (.canvasElement / .playElement) à la place — voir
|
||||
# les gabarits screen_edit.html et play.html.
|
||||
attrs["data-anim-target"] = el["id"]
|
||||
return attrs
|
||||
Reference in New Issue
Block a user