Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 1m0s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-09-04 20:56:32 +02:00
parent 00f7191a8b
commit 50835a18e2
221 changed files with 1239 additions and 12245 deletions
-14
View File
@@ -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
-10
View File
@@ -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)
-2
View File
@@ -1,2 +0,0 @@
def _auto_id(el):
return f"elt-{el['id']}"
-199
View File
@@ -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>'
-13
View File
@@ -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)
-220
View File
@@ -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
-15
View File
@@ -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>'
-24
View File
@@ -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>"
-164
View File
@@ -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>'
)
-66
View File
@@ -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>"
)
-100
View File
@@ -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>"
)
-21
View File
@@ -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)}>"
-79
View File
@@ -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>'
-31
View File
@@ -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>'
-21
View File
@@ -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>'
-36
View File
@@ -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"]}
-80
View File
@@ -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 "")
-69
View File
@@ -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)
-34
View File
@@ -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