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:
+10
-67
@@ -1,34 +1,15 @@
|
||||
"""
|
||||
screens.py — écrans de jeu : éléments posés en glisser-déposer sur un
|
||||
écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans,
|
||||
et actions déclenchées par clic ou soumission de formulaire.
|
||||
|
||||
Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de
|
||||
WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de
|
||||
données...), chacun avec ses propres réglages ("controls") : un réglage =
|
||||
un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom
|
||||
de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget
|
||||
correspond à une vraie balise HTML avec de vrais attributs et un vrai
|
||||
style CSS ; l'utilisateur ne les manipule jamais directement.
|
||||
screens.py — écrans de jeu : un seul moteur d'écran, la scène 2D "jeu_2d"
|
||||
(voir screens/scenes/) — objets posés en pixels, enchaînement des écrans,
|
||||
et logique déclenchée par clic/collision/clavier/minuteur (voir
|
||||
screens/flow/).
|
||||
|
||||
Tout est stocké dans la même base SQLite que le reste du jeu
|
||||
(projects/<slug>/game.db) :
|
||||
- _screens : un écran du jeu (accueil, formulaire, scène...)
|
||||
- _element_types : le "catalogue" des éléments de jeu réutilisables
|
||||
(Personnage, Outil, IA...) que l'utilisateur crée
|
||||
- _screen_elements : chaque élément posé sur un écran (widget, position
|
||||
et taille en POURCENTAGE de l'écran, contenu,
|
||||
attributs et style — dérivés des réglages du
|
||||
widget, jamais saisis en texte libre)
|
||||
- _actions : ce qui se passe au clic / à la soumission d'un
|
||||
élément (écran suivant, écran précédent, aller à
|
||||
un écran précis, ne rien faire)
|
||||
|
||||
Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu
|
||||
fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche
|
||||
automatiquement une ligne par donnée existante — par exemple, tous les
|
||||
niveaux d'un parcours — à partir d'un modèle de texte avec des
|
||||
{{nom_de_champ}}.
|
||||
- _screens : un écran du jeu (une scène...)
|
||||
- _scene_objects : chaque objet posé sur une scène (personnage,
|
||||
décor, fond, widgets d'interface...)
|
||||
- _flow_nodes/_flow_edges : le graphe de logique de chaque écran
|
||||
|
||||
Ce fichier est désormais un package (voir les sous-dossiers) organisé
|
||||
selon "un fichier = une fonction, un dossier = une responsabilité" ; ce
|
||||
@@ -37,20 +18,10 @@ utilisée par app.py et les templates, afin que `import screens` continue
|
||||
de fonctionner à l'identique.
|
||||
"""
|
||||
|
||||
from .widgets.registry import WIDGETS
|
||||
from .widgets.container_widgets import CONTAINER_WIDGETS
|
||||
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
|
||||
from .widgets.widget_meta import widget_meta
|
||||
from .widgets.controls_with_values import controls_with_values
|
||||
from .widgets.data_definition_options import data_definition_options
|
||||
from .data_definitions.data_definition_options import data_definition_options
|
||||
|
||||
from .labels.trigger_labels import TRIGGER_LABELS
|
||||
from .labels.action_type_labels import ACTION_TYPE_LABELS
|
||||
from .labels.element_action_properties import (
|
||||
ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
|
||||
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
|
||||
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS,
|
||||
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
|
||||
)
|
||||
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
|
||||
@@ -78,7 +49,6 @@ from .flow.constants import (
|
||||
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
|
||||
)
|
||||
|
||||
from .rendering.render_element_html import render_element_html
|
||||
from .rendering.trigger_for import _trigger_for
|
||||
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
|
||||
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
|
||||
@@ -99,27 +69,13 @@ from .screens_repo.rename_screen import rename_screen
|
||||
from .screens_repo.delete_screen import delete_screen
|
||||
from .screens_repo.move_screen import move_screen
|
||||
from .screens_repo.screens_referencing import screens_referencing
|
||||
from .screens_repo.set_screen_aspect import set_screen_aspect
|
||||
from .screens_repo.set_scene_size import set_scene_size
|
||||
from .screens_repo.set_screen_background_music import set_screen_background_music
|
||||
|
||||
from .elements.list_elements import list_elements
|
||||
from .elements.get_element import get_element
|
||||
from .elements.add_element import add_element
|
||||
from .elements.add_tab import add_tab
|
||||
from .elements.update_element_geometry import update_element_geometry
|
||||
from .elements.reorder_element import reorder_element
|
||||
from .elements.move_element_to_container import move_element_to_container
|
||||
from .elements.save_element_controls import save_element_controls
|
||||
from .elements.delete_element import delete_element
|
||||
from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element
|
||||
from .elements.duplicate_element import duplicate_element
|
||||
from .elements.rename_element import rename_element
|
||||
from .elements.set_personnage_data import set_personnage_data
|
||||
|
||||
from .scenes.ensure_scene_schema import ensure_scene_schema
|
||||
from .scenes.add_scene_object import add_scene_object
|
||||
from .scenes.list_scene_objects import list_scene_objects
|
||||
from .scenes.resolve_scene_world_size import resolve_scene_world_size
|
||||
from .scenes.get_scene_object import get_scene_object
|
||||
from .scenes.update_scene_object_geometry import update_scene_object_geometry
|
||||
from .scenes.delete_scene_object import delete_scene_object
|
||||
@@ -132,19 +88,6 @@ from .scenes.set_scene_object_name import set_scene_object_name
|
||||
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||
from .scenes.render_scene_object import render_scene_object
|
||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||
from .elements.element_label import element_label
|
||||
|
||||
from .element_types.list_element_types import list_element_types
|
||||
from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen
|
||||
from .element_types.create_element_type import create_element_type
|
||||
from .element_types.update_element_type import update_element_type
|
||||
from .element_types.element_type_usage_count import element_type_usage_count
|
||||
from .element_types.delete_element_type import delete_element_type
|
||||
|
||||
from .legacy_actions.list_actions import list_actions
|
||||
from .legacy_actions.add_action import add_action
|
||||
from .legacy_actions.get_action import get_action
|
||||
from .legacy_actions.delete_action import delete_action
|
||||
|
||||
from .flow.ensure_flow_schema import ensure_flow_schema
|
||||
from .flow.list_flow_nodes import list_flow_nodes
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...)."""
|
||||
@@ -1,21 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..screens_repo.ensure_schema import ensure_schema
|
||||
from ..screens_repo.create_screen import create_screen
|
||||
|
||||
|
||||
def create_element_type(slug, name, icon, bound_definition_id=None):
|
||||
"""Un élément de jeu est un conteneur réutilisable : sa "recette" (les
|
||||
éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle
|
||||
caché, créé ici en même temps que l'entrée de catalogue — on le
|
||||
remplit ensuite exactement comme le contenu de n'importe quel
|
||||
conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle."""
|
||||
ensure_schema(slug)
|
||||
template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)",
|
||||
(name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,15 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def delete_element_type(slug, element_type_id):
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute(
|
||||
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
|
||||
).fetchone()
|
||||
conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,))
|
||||
if row and row["template_screen_id"]:
|
||||
# Supprime aussi l'écran-modèle caché associé — son propre contenu
|
||||
# (_screen_elements) part avec lui via ON DELETE CASCADE.
|
||||
conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,10 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def element_type_usage_count(slug, element_type_id):
|
||||
conn = db.connect(slug)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,)
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
return count
|
||||
@@ -1,16 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..screens_repo.ensure_schema import ensure_schema
|
||||
|
||||
|
||||
def get_element_type_by_template_screen(slug, screen_id):
|
||||
"""L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST
|
||||
cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur
|
||||
d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu
|
||||
(bound_definition_id, réglé à la création — voir element_types.html)
|
||||
sans devoir tout lister soi-même."""
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -1,16 +0,0 @@
|
||||
from .load_template_tree import _load_template_tree
|
||||
|
||||
|
||||
def _is_overlay_only_element_type(slug, element_type_id):
|
||||
"""True si le modèle d'un élément de jeu N'EST QU'une "Superposition /
|
||||
boîte de dialogue" (un seul élément de premier niveau, de ce widget) —
|
||||
utilisé à deux endroits qui doivent s'accorder : render_element_html.py
|
||||
(court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui
|
||||
resterait sinon visible en permanence) et element_style_filter.py (le
|
||||
cadre .canvasElement/.playElement de cet exemplaire ne doit pas non
|
||||
plus poser de z-index, pour la même raison qu'une superposition posée
|
||||
directement — voir son propre commentaire)."""
|
||||
template_tree = _load_template_tree(slug, element_type_id)
|
||||
if not template_tree:
|
||||
return False
|
||||
return {c.get("widget") for c in template_tree["top"]} == {"superposition"}
|
||||
@@ -1,19 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..screens_repo.ensure_schema import ensure_schema
|
||||
|
||||
|
||||
def list_element_types(slug):
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["default_style"] = json.loads(d["default_style"] or "{}")
|
||||
d["default_attributes"] = json.loads(d["default_attributes"] or "{}")
|
||||
result.append(d)
|
||||
return result
|
||||
@@ -1,49 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _load_template_tree(slug, element_type_id):
|
||||
"""Charge le contenu (éléments de premier niveau + descendants) de
|
||||
l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier
|
||||
quoi que ce soit en base) — utilisé quand un Répéteur de données affiche
|
||||
cet élément de jeu comme modèle de ligne (voir _render_repeater) : on
|
||||
relit et on rejoue son arbre à chaque ligne de données, avec les
|
||||
{{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou
|
||||
plus) d'écran-modèle valide."""
|
||||
conn = db.connect(slug)
|
||||
et_row = conn.execute(
|
||||
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
|
||||
).fetchone()
|
||||
if not et_row or not et_row["template_screen_id"]:
|
||||
conn.close()
|
||||
return None
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
|
||||
(et_row["template_screen_id"],),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
elements = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["style"] = json.loads(d["style"] or "{}")
|
||||
d["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
elements.append(d)
|
||||
children_map = {}
|
||||
for d in elements:
|
||||
if d.get("parent_id"):
|
||||
children_map.setdefault(d["parent_id"], []).append(d)
|
||||
top = [d for d in elements if not d.get("parent_id")]
|
||||
if not top:
|
||||
return None
|
||||
# Astuce de rendu : ces éléments sont "de premier niveau" dans leur
|
||||
# écran-modèle (parent_id NULL en base, donc positionnés en absolu par
|
||||
# défaut), mais une fois le modèle réutilisé ailleurs (ligne de
|
||||
# Répéteur), ils doivent se comporter comme des enfants en flux normal,
|
||||
# pas comme des boîtes positionnées en absolu sur un écran plein. On leur
|
||||
# donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien
|
||||
# que pour ce rendu éphémère — _style_string ne teste que sa présence,
|
||||
# jamais sa valeur exacte, et cette copie n'est jamais enregistrée.
|
||||
for t in top:
|
||||
t["parent_id"] = t.get("parent_id") or "template-root"
|
||||
return {"top": top, "children_map": children_map}
|
||||
@@ -1,13 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def update_element_type(slug, element_type_id, name=None, bound_definition_id=None):
|
||||
conn = db.connect(slug)
|
||||
if name:
|
||||
conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id))
|
||||
conn.execute(
|
||||
"UPDATE _element_types SET bound_definition_id = ? WHERE id = ?",
|
||||
(int(bound_definition_id) if bound_definition_id else None, element_type_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
"""Éléments posés sur un écran (_screen_elements)."""
|
||||
@@ -1,87 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..widgets.registry import WIDGETS
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.default_style_for_widget import default_style_and_attributes
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
|
||||
"""parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur
|
||||
de cet élément parent (qui doit être un conteneur/répéteur/groupe de
|
||||
champs) plutôt que directement sur l'écran — sa position/taille ne sert
|
||||
alors plus (il suit le flux du parent), mais on garde des valeurs par
|
||||
défaut pour rester cohérent si jamais il en sort un jour.
|
||||
|
||||
element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
|
||||
comme n'importe quel widget), qui garde juste une référence vers cet
|
||||
élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
|
||||
copié en base : il est rechargé EN DIRECT à chaque affichage (voir
|
||||
render_element_html.py), exactement comme un modèle de ligne de
|
||||
Répéteur (voir _load_template_tree/_render_repeater). 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 — au prix de ne plus pouvoir personnaliser
|
||||
individuellement le CONTENU d'un exemplaire précis (sa position/taille
|
||||
sur l'écran, elles, restent propres à chaque exemplaire, comme
|
||||
d'habitude)."""
|
||||
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
|
||||
if element_type_id:
|
||||
types = {t["id"]: t for t in list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
if et:
|
||||
widget_key = et.get("default_widget") or widget_key
|
||||
meta = widget_meta(widget_key)
|
||||
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
|
||||
# création (ex: une image démarre avec "cover" pour se recadrer
|
||||
# proprement) — hors couleurs, qui restent volontairement transparentes/
|
||||
# sans bordure tant qu'on ne les choisit pas explicitement.
|
||||
attributes, style = default_style_and_attributes(widget_key)
|
||||
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
|
||||
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
|
||||
# de dialogue" (render_element_html.py décide alors, au rendu, de
|
||||
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
|
||||
# démarre masqué par défaut, exactement comme une superposition
|
||||
# posée directement (voir default_style_for_widget.py) — sinon un
|
||||
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
|
||||
# sa pose, avant même qu'une action ne l'affiche.
|
||||
style["display"] = "none"
|
||||
content = meta.get("default_content", "")
|
||||
width, height = meta.get("w", 30), meta.get("h", 10)
|
||||
|
||||
conn = db.connect(slug)
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,)
|
||||
).fetchone()["m"]
|
||||
# Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT
|
||||
# SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le
|
||||
# précédent et le recouvre entièrement — ce qui peut donner l'impression
|
||||
# trompeuse qu'un "élément indésirable" vient d'apparaître et d'en
|
||||
# cacher un autre (le plus souvent après une confusion entre "Ajouter un
|
||||
# élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous
|
||||
# les deux un nouvel élément mais pas au même endroit dans l'arbre).
|
||||
x = y = 10
|
||||
if not parent_id:
|
||||
top_level_count = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL",
|
||||
(screen_id,),
|
||||
).fetchone()["c"]
|
||||
offset = (top_level_count % 8) * 4
|
||||
x = y = 10 + offset
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements
|
||||
(screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes),
|
||||
x, y, width, height, (max_z or 0) + 1,
|
||||
json.dumps(style),
|
||||
),
|
||||
)
|
||||
element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return element_id
|
||||
@@ -1,19 +0,0 @@
|
||||
import db
|
||||
|
||||
from .add_element import add_element
|
||||
from .rename_element import rename_element
|
||||
|
||||
|
||||
def add_tab(slug, onglets_id, screen_id):
|
||||
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
|
||||
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
|
||||
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
|
||||
modifiable ensuite comme celui de n'importe quel élément."""
|
||||
conn = db.connect(slug)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
|
||||
rename_element(slug, tab_id, f"Onglet {count + 1}")
|
||||
return tab_id
|
||||
@@ -1,50 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..animations.ensure_animation_schema import ensure_animation_schema
|
||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||
from .element_descendant_ids import element_descendant_ids
|
||||
|
||||
|
||||
def delete_element(slug, element_id):
|
||||
ensure_flow_schema(slug)
|
||||
ensure_animation_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return
|
||||
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
|
||||
# élément"/action "Modifier cet élément"/condition de collision — voir
|
||||
# ci-dessous...) ou une ancienne action du système _actions (conservé
|
||||
# pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES
|
||||
# DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces
|
||||
# colonnes (trigger_element_id/target_element_id) n'ont volontairement
|
||||
# PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être
|
||||
# supprimé "par erreur" en cascade depuis un nœud de logique qu'on
|
||||
# modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir
|
||||
# db/connection.py) fait échouer la suppression elle-même avec
|
||||
# "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour
|
||||
# prévenir l'utilisateur AVANT qu'il confirme, plutôt que de supprimer
|
||||
# ces nœuds en silence.
|
||||
#
|
||||
# cond_element_a/cond_element_b (Phase 5, condition de collision) : ni
|
||||
# contrainte FK ni ON DELETE CASCADE (voir ensure_flow_schema.py), mais
|
||||
# un nœud qui compare le rectangle de CET élément à un autre n'a pas
|
||||
# plus de sens que si c'était trigger_element_id/target_element_id —
|
||||
# supprimé ici pour la même raison, pas parce que la base l'exigerait.
|
||||
conn.execute(
|
||||
f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
|
||||
OR target_element_id IN ({placeholders})
|
||||
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
|
||||
ids + ids + ids + ids,
|
||||
)
|
||||
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
|
||||
# _animation_clips.element_id n'a plus de contrainte FK (voir
|
||||
# ensure_animation_schema.py — réutilisé aussi par les objets de scène
|
||||
# jeu_2d) : nettoyage manuel, même raison que ci-dessus.
|
||||
conn.execute(f"DELETE FROM _animation_clips WHERE element_id IN ({placeholders})", ids)
|
||||
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,60 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def duplicate_element(slug, element_id):
|
||||
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
|
||||
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
|
||||
de l'arborescence n'a pas à faire choisir où poser la copie, elle
|
||||
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
|
||||
sélectionner ensuite), ou None si l'élément n'existe pas."""
|
||||
conn = db.connect(slug)
|
||||
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not root_row:
|
||||
conn.close()
|
||||
return None
|
||||
root = dict(root_row)
|
||||
source = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
|
||||
).fetchall()
|
||||
]
|
||||
conn.close()
|
||||
|
||||
def copy_one(src, new_parent_id, is_root):
|
||||
conn = db.connect(slug)
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
|
||||
).fetchone()["m"]
|
||||
x, y = src["x"], src["y"]
|
||||
if is_root and not new_parent_id:
|
||||
# Petit décalage pour que la copie ne recouvre pas exactement
|
||||
# l'original (qui serait alors invisible en dessous).
|
||||
x = min(100, (x or 0) + 4)
|
||||
y = min(100, (y or 0) + 4)
|
||||
name = src.get("name")
|
||||
if is_root and name:
|
||||
name = f"{name} (copie)"
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements
|
||||
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
|
||||
x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
|
||||
src["content"], src["attributes"], x, y, src["width"], src["height"],
|
||||
(max_z or 0) + 1, src["style"],
|
||||
),
|
||||
)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
def walk(src_parent_id, new_parent_id):
|
||||
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
|
||||
new_child_id = copy_one(child, new_parent_id, is_root=False)
|
||||
walk(child["id"], new_child_id)
|
||||
|
||||
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
|
||||
walk(root["id"], new_root_id)
|
||||
return new_root_id
|
||||
@@ -1,23 +0,0 @@
|
||||
def element_descendant_ids(conn, screen_id, root_id):
|
||||
"""Id de cet élément et de TOUS ses descendants — ceux qu'une
|
||||
suppression en cascade (parent_id ON DELETE CASCADE, voir
|
||||
ensure_schema.py) supprime automatiquement en même temps que lui.
|
||||
Partagé entre delete_element.py (nettoyage des références de la
|
||||
Logique de la scène avant suppression) et flow_nodes_referencing_
|
||||
element.py (pour prévenir l'utilisateur AVANT qu'il confirme la
|
||||
suppression, voir routes/elements/element_delete_impact.py)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
|
||||
).fetchall()
|
||||
children_of = {}
|
||||
for r in rows:
|
||||
if r["parent_id"]:
|
||||
children_of.setdefault(r["parent_id"], []).append(r["id"])
|
||||
ids = [root_id]
|
||||
stack = [root_id]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for child_id in children_of.get(current, []):
|
||||
ids.append(child_id)
|
||||
stack.append(child_id)
|
||||
return ids
|
||||
@@ -1,10 +0,0 @@
|
||||
from ..widgets.registry import WIDGETS
|
||||
|
||||
|
||||
def element_label(el, widgets_catalog=None):
|
||||
"""Nom lisible d'un élément pour les listes/menus : son nom personnalisé
|
||||
s'il en a un, sinon le libellé de son widget (ex: "Image")."""
|
||||
catalog = widgets_catalog if widgets_catalog is not None else WIDGETS
|
||||
if el.get("name"):
|
||||
return el["name"]
|
||||
return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?"))
|
||||
@@ -1,30 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||
from .element_descendant_ids import element_descendant_ids
|
||||
|
||||
|
||||
def flow_nodes_referencing_element(slug, element_id):
|
||||
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
|
||||
élément", action "Modifier cet élément"/"Activer cet onglet", condition
|
||||
de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES
|
||||
DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément
|
||||
(voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT
|
||||
qu'il confirme la suppression (voir la modale de confirmation dans
|
||||
screen_edit.html), plutôt que de les supprimer en silence."""
|
||||
ensure_flow_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return 0
|
||||
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
count = conn.execute(
|
||||
f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
|
||||
OR target_element_id IN ({placeholders})
|
||||
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
|
||||
ids + ids + ids + ids,
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
return count
|
||||
@@ -1,15 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def get_element(slug, element_id):
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["style"] = json.loads(d["style"] or "{}")
|
||||
d["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
return d
|
||||
@@ -1,95 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..rendering.render_element_html import render_element_html
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def list_elements(slug, screen_id, enforce_visibility=False, player_id=db.PLAYER_SHARED):
|
||||
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
|
||||
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
|
||||
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
|
||||
celui d'un conteneur/répéteur/groupe de champs inclut, en le générant
|
||||
récursivement, le HTML de tout ce qui est posé à l'intérieur — un
|
||||
élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le
|
||||
rendered_html de son ancêtre de premier niveau, utilisé pour le rendu
|
||||
HTML initial de la page ; une fois dans le sien propre) : c'est ce
|
||||
dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/
|
||||
refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué
|
||||
(ex. un panneau de détail posé dans un conteneur) sans reconstruire tout
|
||||
son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce
|
||||
changement, seuls les éléments de premier niveau recevaient un
|
||||
rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui
|
||||
retombait donc toujours sur la régénération de son ancêtre de premier
|
||||
niveau, quelle que soit la finesse du ciblage côté JS.
|
||||
|
||||
enforce_visibility : True uniquement pour le mode jouable (voir
|
||||
full_game_payload.py) — applique la "condition de visibilité" de chaque
|
||||
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
|
||||
qui doit continuer à montrer/sélectionner un élément même si sa
|
||||
condition serait actuellement fausse.
|
||||
|
||||
player_id (état par joueur, Phase 1) : posé dans ctx["_forge_player_id"]
|
||||
pour tout le rendu en aval (Répéteur/Jauge/Donnée liée/condition de
|
||||
visibilité) — quel joueur consulte cet écran, pour qu'un objet/une
|
||||
variable per_player affiche SA valeur (voir db/global_vars/,
|
||||
db/rows/)."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["style"] = json.loads(d["style"] or "{}")
|
||||
d["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
result.append(d)
|
||||
|
||||
# Descendant d'un exemplaire d'élément de jeu (element_type_id réglé sur
|
||||
# un ancêtre) : un ancien enfant copié en base par l'ex-instantiate_
|
||||
# template_tree (mécanisme retiré, voir add_element.py) — son contenu
|
||||
# vient désormais TOUJOURS en direct de l'écran-modèle au moment du
|
||||
# rendu (voir render_element_html.py/_render_element_type_children),
|
||||
# jamais de ces lignes, qui peuvent encore traîner en base pour un
|
||||
# exemplaire posé avant ce changement. Exclu ici pour ne plus jamais
|
||||
# apparaître sélectionnable dans l'arborescence de l'éditeur — sinon
|
||||
# ses réglages, plus jamais synchronisés avec le modèle, donnaient
|
||||
# l'impression trompeuse que "les propriétés ne sont pas prises en
|
||||
# compte" (une taille de texte modifiée dans le modèle ne se reflétant
|
||||
# jamais sur cette ligne fantôme, toujours sélectionnable à l'écran).
|
||||
by_id = {d["id"]: d for d in result}
|
||||
|
||||
def _has_linked_ancestor(d):
|
||||
seen = set()
|
||||
parent_id = d.get("parent_id")
|
||||
while parent_id and parent_id not in seen:
|
||||
seen.add(parent_id)
|
||||
parent = by_id.get(parent_id)
|
||||
if not parent:
|
||||
return False
|
||||
if parent.get("element_type_id"):
|
||||
return True
|
||||
parent_id = parent.get("parent_id")
|
||||
return False
|
||||
|
||||
result = [d for d in result if not _has_linked_ancestor(d)]
|
||||
|
||||
children_map = {}
|
||||
for d in result:
|
||||
if d.get("parent_id"):
|
||||
children_map.setdefault(d["parent_id"], []).append(d)
|
||||
play_ctx = {"_forge_play_mode": True, "_forge_player_id": player_id} if enforce_visibility else None
|
||||
for d in result:
|
||||
# Un exemplaire de premier niveau d'un élément de jeu dont le
|
||||
# modèle N'EST QU'une superposition : son cadre de positionnement
|
||||
# (.canvasElement/.playElement, voir filters/element_style_filter.py)
|
||||
# ne doit pas non plus poser de z-index, pour la même raison qu'une
|
||||
# superposition posée directement (sinon elle crée un contexte
|
||||
# d'empilement CSS qui piège le z-index:9999 de la superposition
|
||||
# rendue à l'intérieur — voir element_style_filter.py).
|
||||
if d.get("element_type_id") and not d.get("parent_id"):
|
||||
d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"])
|
||||
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
|
||||
return result
|
||||
@@ -1,62 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..widgets.container_widgets import CONTAINER_WIDGETS
|
||||
|
||||
|
||||
def move_element_to_container(slug, element_id, new_parent_id):
|
||||
"""Déplace element_id pour qu'il devienne un enfant de new_parent_id
|
||||
(glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en
|
||||
DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme
|
||||
d'habitude par glisser-déposer entre frères et sœurs (voir
|
||||
reorder_element.py). Refuse silencieusement (ne touche à rien) si :
|
||||
new_parent_id n'est pas un widget conteneur, si c'est element_id
|
||||
lui-même, ou si c'est un de ses propres descendants (ça créerait un
|
||||
cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même)."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)",
|
||||
(element_id, new_parent_id),
|
||||
).fetchall()
|
||||
by_id = {r["id"]: r for r in rows}
|
||||
el = by_id.get(element_id)
|
||||
target = by_id.get(new_parent_id)
|
||||
if not el or not target or el["screen_id"] != target["screen_id"]:
|
||||
conn.close()
|
||||
return
|
||||
if target["widget"] not in CONTAINER_WIDGETS:
|
||||
conn.close()
|
||||
return
|
||||
if new_parent_id == element_id:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Cycle : new_parent_id ne doit pas être element_id, ni un descendant de
|
||||
# element_id (direct ou indirect) — sinon element_id finirait posé à
|
||||
# l'intérieur de sa propre arborescence.
|
||||
children_of = {}
|
||||
for r in conn.execute(
|
||||
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
|
||||
).fetchall():
|
||||
if r["parent_id"]:
|
||||
children_of.setdefault(r["parent_id"], []).append(r["id"])
|
||||
stack = list(children_of.get(element_id, []))
|
||||
descendants = set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node in descendants:
|
||||
continue
|
||||
descendants.add(node)
|
||||
stack.extend(children_of.get(node, []))
|
||||
if new_parent_id in descendants:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
|
||||
).fetchone()["m"]
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?",
|
||||
(new_parent_id, (max_z or 0) + 1, element_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def rename_element(slug, element_id, name):
|
||||
conn = db.connect(slug)
|
||||
conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,37 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def reorder_element(slug, screen_id, element_id, target_id, position):
|
||||
"""Déplace element_id juste avant ("before") ou juste après ("after")
|
||||
target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même
|
||||
parent_id — sinon on ne touche à rien, pour ne jamais reparenter par
|
||||
erreur via le glisser-déposer de l'arborescence). L'ordre est porté par
|
||||
z_index, partagé par tous les éléments de l'écran (pas juste les frères
|
||||
et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel
|
||||
(z_index, id) pour rester cohérent partout, pas seulement dans ce
|
||||
conteneur."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
|
||||
(screen_id,),
|
||||
).fetchall()
|
||||
by_id = {r["id"]: r["parent_id"] for r in rows}
|
||||
if element_id not in by_id or target_id not in by_id:
|
||||
conn.close()
|
||||
return
|
||||
if by_id[element_id] != by_id[target_id]:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
order = [r["id"] for r in rows]
|
||||
order.remove(element_id)
|
||||
target_index = order.index(target_id)
|
||||
insert_at = target_index + 1 if position == "after" else target_index
|
||||
order.insert(insert_at, element_id)
|
||||
|
||||
conn.executemany(
|
||||
"UPDATE _screen_elements SET z_index = ? WHERE id = ?",
|
||||
[(i + 1, eid) for i, eid in enumerate(order)],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,227 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
from ..clause_list_codec import encode_clauses
|
||||
from .get_element import get_element
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color):
|
||||
h = (hex_color or "").lstrip("#")
|
||||
if len(h) != 6:
|
||||
return (0, 0, 0)
|
||||
try:
|
||||
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def _is_default_value(control, raw_value):
|
||||
"""Compare la valeur reçue du formulaire à la valeur par défaut du
|
||||
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
|
||||
savoir si le champ a simplement été renvoyé tel quel sans être
|
||||
personnalisé."""
|
||||
default = control.get("default", "")
|
||||
if control["type"] == "slider":
|
||||
unit = control.get("unit", "")
|
||||
default = f"{default}{unit}" if default not in (None, "") else ""
|
||||
return str(raw_value) == str(default)
|
||||
|
||||
|
||||
def save_element_controls(slug, element_id, form):
|
||||
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
|
||||
par le panneau de propriétés (un seul formulaire, un champ par réglage)
|
||||
et met à jour content / attributs / style en conséquence."""
|
||||
el = get_element(slug, element_id)
|
||||
if not el:
|
||||
return
|
||||
meta = widget_meta(el["widget"])
|
||||
content = el["content"]
|
||||
attributes = dict(el["attributes"])
|
||||
style = dict(el["style"])
|
||||
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
|
||||
|
||||
for control in meta.get("controls", []):
|
||||
ctype = control["type"]
|
||||
field_name = "ctrl_" + control["key"]
|
||||
|
||||
if ctype == "toggle_token":
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
toggle_groups.setdefault(name, set())
|
||||
if form.get(field_name) is not None:
|
||||
toggle_groups[name].add(control["token"])
|
||||
continue
|
||||
|
||||
if ctype == "clause_list":
|
||||
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
|
||||
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
|
||||
# "_valeur[]"), reconstituées ici en une liste de clauses par
|
||||
# position (zip) — même principe que field_name[]/field_type[]
|
||||
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
|
||||
champs = form.getlist(field_name + "_champ[]")
|
||||
operateurs = form.getlist(field_name + "_operateur[]")
|
||||
valeurs = form.getlist(field_name + "_valeur[]")
|
||||
clauses = [
|
||||
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
|
||||
"valeur": valeurs[i] if i < len(valeurs) else ""}
|
||||
for i, champ in enumerate(champs)
|
||||
]
|
||||
encoded = encode_clauses(clauses)
|
||||
if encoded:
|
||||
attributes[control["clauses_attr"]] = encoded
|
||||
else:
|
||||
attributes.pop(control["clauses_attr"], None)
|
||||
combinateur = form.get(field_name + "_combinateur") or "et"
|
||||
if combinateur == "ou":
|
||||
attributes[control["combinator_attr"]] = combinateur
|
||||
else:
|
||||
attributes.pop(control["combinator_attr"], None)
|
||||
continue
|
||||
|
||||
if ctype == "preset":
|
||||
chosen = form.get(field_name, control.get("default"))
|
||||
opt = next((o for o in control["options"] if o[0] == chosen), None)
|
||||
if opt:
|
||||
for k, v in opt[2].items():
|
||||
if v in (None, ""):
|
||||
style.pop(k, None)
|
||||
else:
|
||||
style[k] = v
|
||||
if (
|
||||
control["key"] == "visibilite" and chosen == "visible"
|
||||
and el["widget"] not in LAYOUT_CAPABLE_WIDGETS
|
||||
):
|
||||
# "Visible" ne touche volontairement pas "display" (voir
|
||||
# visibility_control.py) : sur un widget qui A un
|
||||
# réglage "Disposition interne" (LAYOUT_CONTROLS,
|
||||
# traité avant celui-ci sur le MÊME enregistrement,
|
||||
# voir widget_meta.py), ce contrôle réaffirme lui-même
|
||||
# un "display" non-"none" (flex/etc), donc "Visible" n'a
|
||||
# rien à faire. Mais un widget SANS ce contrôle (ex.
|
||||
# "superposition") n'a PERSONNE d'autre pour l'effacer :
|
||||
# un "display:none" laissé par un "Masqué" précédent (ou
|
||||
# par le réglage par défaut à la création, voir
|
||||
# default_style_for_widget.py) restait donc bloqué
|
||||
# masqué pour toujours, quel que soit le nombre de fois
|
||||
# où "Visible" était ensuite choisi.
|
||||
style.pop("display", None)
|
||||
continue
|
||||
|
||||
if ctype == "scale":
|
||||
try:
|
||||
val = float(form.get(field_name, control.get("default", 1)))
|
||||
except (TypeError, ValueError):
|
||||
val = 1.0
|
||||
if abs(val - 1) < 1e-6:
|
||||
style.pop("transform", None)
|
||||
else:
|
||||
style["transform"] = f"scale({val})"
|
||||
continue
|
||||
|
||||
if ctype == "size":
|
||||
# Un seul champ CSS ("width"/"height") mais deux entrées de
|
||||
# formulaire (valeur + unité) — voir size_override_controls.py
|
||||
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
try:
|
||||
num = float(form.get(field_name + "_value") or 0)
|
||||
except (TypeError, ValueError):
|
||||
num = 0.0
|
||||
unit = form.get(field_name + "_unit", "%")
|
||||
if unit not in ("px", "%"):
|
||||
unit = "%"
|
||||
if num <= 0:
|
||||
style.pop(name, None)
|
||||
else:
|
||||
num_str = str(int(num)) if num == int(num) else str(num)
|
||||
style[name] = f"{num_str}{unit}"
|
||||
continue
|
||||
|
||||
if ctype == "shadow":
|
||||
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
|
||||
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
|
||||
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
|
||||
# <input type="color"> seul ne portant pas de canal alpha).
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
defaults = control.get("default", {})
|
||||
|
||||
def _num(suffix, fallback):
|
||||
try:
|
||||
return float(form.get(field_name + suffix, fallback))
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
x = _num("_x", defaults.get("x", 0))
|
||||
y = _num("_y", defaults.get("y", 4))
|
||||
blur = _num("_blur", defaults.get("blur", 12))
|
||||
spread = _num("_spread", defaults.get("spread", 0))
|
||||
opacity = _num("_opacity", defaults.get("opacity", 0.4))
|
||||
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
|
||||
if x == 0 and y == 0 and blur == 0 and spread == 0:
|
||||
style.pop(name, None)
|
||||
else:
|
||||
r, g, b = _hex_to_rgb(color)
|
||||
|
||||
def _px(n):
|
||||
return str(int(n)) if n == int(n) else str(n)
|
||||
|
||||
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
|
||||
continue
|
||||
|
||||
target = control["target"]
|
||||
if ctype == "checkbox":
|
||||
checked = form.get(field_name) is not None
|
||||
raw_value = control["true_value"] if checked else control["false_value"]
|
||||
else:
|
||||
raw_value = form.get(field_name, control.get("default", ""))
|
||||
if ctype == "slider":
|
||||
unit = control.get("unit", "")
|
||||
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
|
||||
|
||||
if target == "content":
|
||||
content = raw_value
|
||||
else:
|
||||
kind, name = target.split(":", 1)
|
||||
if kind == "class":
|
||||
# "class:xxx" : un jeton de classe CSS externe (ex. un
|
||||
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
|
||||
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
|
||||
# voir _visible_attrs.py qui les rassemble avec la classe de
|
||||
# base du widget (fixed_attrs) au moment du rendu.
|
||||
store, name = attributes, "_class_" + name
|
||||
else:
|
||||
store = attributes if kind == "attr" else style
|
||||
if raw_value in (None, ""):
|
||||
store.pop(name, None)
|
||||
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
|
||||
# Ce réglage n'a jamais été personnalisé (la clé est absente
|
||||
# du style/des attributs stockés) et la valeur reçue est
|
||||
# simplement celle par défaut affichée dans le champ (un
|
||||
# <input type="color"> ou un slider a TOUJOURS une valeur à
|
||||
# l'écran, même quand rien n'a été choisi). Comme le
|
||||
# panneau de propriétés envoie TOUS les réglages du widget
|
||||
# en un seul formulaire, sauvegarder n'importe quel autre
|
||||
# champ (ex. changer la disposition) écrirait sinon cette
|
||||
# valeur par défaut "en dur" dans l'élément — ce qui faisait
|
||||
# apparaître par exemple une couleur de fond qui n'avait
|
||||
# jamais été demandée. On laisse donc ce réglage "non
|
||||
# défini" tant qu'il n'a pas été explicitement changé.
|
||||
pass
|
||||
else:
|
||||
store[name] = raw_value
|
||||
|
||||
for prop, tokens in toggle_groups.items():
|
||||
if tokens:
|
||||
style[prop] = " ".join(sorted(tokens))
|
||||
else:
|
||||
style.pop(prop, None)
|
||||
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
|
||||
(content, json.dumps(attributes), json.dumps(style), element_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,20 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def set_personnage_data(slug, element_id, data):
|
||||
"""Fusionne _personnage_data (voir screens/rendering/personnage_data.py)
|
||||
dans les attributs déjà stockés de cet élément — remplace la source
|
||||
(Forge/personnalisée) et son contenu (personnage choisi, ou animations
|
||||
importées) en un seul appel, jamais les autres attributs de l'élément."""
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT attributes FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return
|
||||
attributes = json.loads(row["attributes"] or "{}")
|
||||
attributes["_personnage_data"] = json.dumps(data)
|
||||
conn.execute("UPDATE _screen_elements SET attributes = ? WHERE id = ?", (json.dumps(attributes), element_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,14 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def update_element_geometry(slug, element_id, x, y, width, height):
|
||||
"""Utilisé par le glisser-déposer / redimensionnement (et par les champs
|
||||
Position & taille du panneau de droite) — ne touche qu'à la position et
|
||||
à la taille, rien d'autre."""
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?",
|
||||
(x, y, width, height, element_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -11,7 +11,7 @@ def ensure_flow_schema(slug):
|
||||
node_type TEXT NOT NULL,
|
||||
x REAL NOT NULL DEFAULT 40,
|
||||
y REAL NOT NULL DEFAULT 40,
|
||||
trigger_element_id INTEGER REFERENCES _screen_elements(id),
|
||||
trigger_element_id INTEGER,
|
||||
trigger_event TEXT,
|
||||
cond_definition_id INTEGER,
|
||||
cond_row_id INTEGER,
|
||||
@@ -21,7 +21,7 @@ def ensure_flow_schema(slug):
|
||||
cond_value TEXT,
|
||||
action_type TEXT,
|
||||
target_screen_id INTEGER REFERENCES _screens(id),
|
||||
target_element_id INTEGER REFERENCES _screen_elements(id),
|
||||
target_element_id INTEGER,
|
||||
element_property TEXT,
|
||||
element_value TEXT,
|
||||
target_definition_id INTEGER,
|
||||
|
||||
@@ -1,66 +1,10 @@
|
||||
# ---------- Actions : modifier un élément ----------
|
||||
|
||||
ELEMENT_ACTION_PROPERTIES = [
|
||||
("visibilite", "Visibilité"),
|
||||
# Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un
|
||||
# créateur de reconstruire l'ancien comportement "texte affiché au
|
||||
# survol" (et bien d'autres) à la main dans la Logique de la scène —
|
||||
# poser "Modifier un élément → Contenu" sur un déclencheur "Au survol"
|
||||
# d'un AUTRE élément. La valeur est un texte littéral (pas encore de
|
||||
# binding vers un champ d'objet ici — voir applyOpenRowBindings pour le
|
||||
# mécanisme équivalent, propre à "Ouvrir la ligne cliquée").
|
||||
("contenu", "Contenu — texte affiché"),
|
||||
("bg_color", "Couleur de fond"),
|
||||
("border_color", "Couleur de bordure"),
|
||||
("width", "Largeur en %"),
|
||||
("height", "Hauteur en %"),
|
||||
# 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
# clignote autour de l'élément, pour attirer l'attention du joueur vers
|
||||
# ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) —
|
||||
# sans avoir à poser une bordure de couleur togglée à la main. Voir
|
||||
# ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html.
|
||||
("surbrillance", "Surbrillance — liseré clignotant"),
|
||||
# 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
|
||||
# ignore les clics, DISTINCT de "invisible" (qui garde sa place mais
|
||||
# disparaît visuellement) — un bouton "Répondre" qui reste visible mais
|
||||
# devient inerte une fois le mail traité, par exemple. Voir
|
||||
# ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans
|
||||
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
|
||||
# de se redéclencher tant que l'élément est désactivé).
|
||||
("desactive", "Désactivé — grisé, ignore les clics"),
|
||||
# Phase 5 (moteur) — déplacement d'élément : "absolu" fixe la position
|
||||
# (en % du cadre, comme Largeur/Hauteur ci-dessus — les éléments sont
|
||||
# déjà positionnés ainsi, voir filters/element_style_filter.py) ;
|
||||
# "relatif" AJOUTE la valeur à la position actuelle plutôt que de
|
||||
# l'écraser (voir applyElementProperty() dans static/js/play/actions.js)
|
||||
# — combiné à un déclencheur clavier (Phase 3), ça permet un
|
||||
# personnage/objet qui se déplace pas à pas à chaque appui de touche.
|
||||
("pos_x", "Position horizontale en % (absolue)"),
|
||||
("pos_y", "Position verticale en % (absolue)"),
|
||||
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
|
||||
("pos_y_relatif", "Déplacer verticalement de... % (relatif)"),
|
||||
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
|
||||
# l'élément (utile pour un personnage qui se déplace au clavier, voir
|
||||
# applyElementProperty() dans static/js/play/actions.js — retourne
|
||||
# l'élément en miroir plutôt que de nécessiter une 2e feuille de
|
||||
# sprites "vue de dos").
|
||||
("orientation", "Orientation — sens du personnage"),
|
||||
]
|
||||
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
|
||||
|
||||
ELEMENT_SURBRILLANCE_VALUES = [
|
||||
("on", "Activer la surbrillance"),
|
||||
("off", "Désactiver la surbrillance"),
|
||||
("toggle", "Basculer activée / désactivée"),
|
||||
]
|
||||
ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES)
|
||||
|
||||
ELEMENT_DESACTIVE_VALUES = [
|
||||
("on", "Désactiver — griser, ignorer les clics"),
|
||||
("off", "Réactiver"),
|
||||
("toggle", "Basculer désactivé / réactivé"),
|
||||
]
|
||||
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
|
||||
# ---------- Actions : orientation d'un objet de scène (jeu_2d) ----------
|
||||
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
|
||||
# l'objet (utile pour un personnage qui se déplace au clavier, voir
|
||||
# applyElementProperty() dans static/js/play/actions.js — retourne
|
||||
# l'élément en miroir plutôt que de nécessiter une 2e feuille de sprites
|
||||
# "vue de dos"). Repris par OBJECT_ACTION_PROPERTIES (screens/scenes/
|
||||
# flow_palette.py) et routes/scenes/scene_edit_view.py.
|
||||
|
||||
ELEMENT_ORIENTATION_VALUES = [
|
||||
("droite", "Faire face à droite"),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Ancien système d'actions par élément (_actions) — conservé pour ne rien
|
||||
casser sur les jeux déjà créés, remplacé par le module flow/."""
|
||||
@@ -1,25 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def add_action(
|
||||
slug, element_id, trigger_event, action_type, target_screen_id=None,
|
||||
target_element_id=None, element_property=None, element_value=None,
|
||||
target_definition_id=None, target_row_id=None, target_field=None,
|
||||
data_operation=None, data_value=None,
|
||||
):
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"""INSERT INTO _actions
|
||||
(element_id, trigger_event, action_type, target_screen_id,
|
||||
target_element_id, element_property, element_value,
|
||||
target_definition_id, target_row_id, target_field, data_operation, data_value)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
element_id, trigger_event, action_type, target_screen_id or None,
|
||||
target_element_id or None, element_property or None, element_value or None,
|
||||
target_definition_id or None, target_row_id or None, target_field or None,
|
||||
data_operation or None, data_value or None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def delete_action(slug, action_id):
|
||||
conn = db.connect(slug)
|
||||
conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def get_action(slug, action_id):
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def list_actions(slug, element_id):
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,12 +1,9 @@
|
||||
import db
|
||||
|
||||
from ..screens_repo.list_screens import list_screens
|
||||
from ..elements.list_elements import list_elements
|
||||
from ..flow.list_flow_nodes import list_flow_nodes
|
||||
from ..flow.list_flow_edges import list_flow_edges
|
||||
from ..animations.list_animation_clips import list_animation_clips
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
from ..rendering.personnage_data import resolve_personnage_animations
|
||||
from ..rendering.personnage_commands import resolve_personnage_commands
|
||||
from ..rendering.personnage_role import resolve_personnage_role
|
||||
@@ -45,59 +42,43 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
flows = {}
|
||||
animations = {}
|
||||
for s in screens_:
|
||||
if s["kind"] == "jeu_2d":
|
||||
objects = list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = render_scene_object(o)
|
||||
# Déplacement/animation automatiques (voir
|
||||
# static/js/play/personnage-controller.js) : chaque
|
||||
# personnage porte ses touches et son rôle déjà résolus
|
||||
# (défauts comblés) pour que le runtime client n'ait
|
||||
# aucune logique de repli à dupliquer — seul un
|
||||
# personnage "joueur" est déplacé/suivi par la caméra.
|
||||
if o["kind"] == "personnage":
|
||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||
o["personnage_role"] = resolve_personnage_role(o)
|
||||
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
|
||||
# Widgets d'interface (voir "🖥️ Interface",
|
||||
# screens/rendering/dialogue_box_style.py) : ni
|
||||
# collision, ni cible/source de règle — juste leur
|
||||
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||
continue
|
||||
# Boîte de collision (voir static/js/play/conditions.js::
|
||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||
# seulement "personnage" — un décor/fond peut aussi servir
|
||||
# d'obstacle.
|
||||
o["collision"] = resolve_collision_settings(o)
|
||||
# Règles "à la collision/dans un périmètre -> action" (voir
|
||||
# "🧩 Collision", templates/scene_edit.html, et
|
||||
# static/js/play/collision-rules-controller.js pour leur
|
||||
# exécution) — jamais sur "fond" (jamais une cible), ni sur
|
||||
# le personnage "joueur" lui-même (ce sont TOUJOURS les
|
||||
# AUTRES objets qui réagissent à SA présence, jamais
|
||||
# l'inverse).
|
||||
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
||||
o["collision_rules"] = resolve_collision_rules(o)
|
||||
payload_screens.append({**s, "elements": objects})
|
||||
else:
|
||||
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
||||
payload_screens.append({**s, "elements": elements})
|
||||
objects = list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = render_scene_object(o)
|
||||
# Déplacement/animation automatiques (voir
|
||||
# static/js/play/personnage-controller.js) : chaque
|
||||
# personnage porte ses touches et son rôle déjà résolus
|
||||
# (défauts comblés) pour que le runtime client n'ait
|
||||
# aucune logique de repli à dupliquer — seul un
|
||||
# personnage "joueur" est déplacé/suivi par la caméra.
|
||||
if o["kind"] == "personnage":
|
||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||
o["personnage_role"] = resolve_personnage_role(o)
|
||||
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
|
||||
# Widgets d'interface (voir "🖥️ Interface",
|
||||
# screens/rendering/dialogue_box_style.py) : ni
|
||||
# collision, ni cible/source de règle — juste leur
|
||||
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||
continue
|
||||
# Boîte de collision (voir static/js/play/conditions.js::
|
||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||
# seulement "personnage" — un décor/fond peut aussi servir
|
||||
# d'obstacle.
|
||||
o["collision"] = resolve_collision_settings(o)
|
||||
# Règles "à la collision/dans un périmètre -> action" (voir
|
||||
# "🧩 Collision", templates/scene_edit.html, et
|
||||
# static/js/play/collision-rules-controller.js pour leur
|
||||
# exécution) — jamais sur "fond" (jamais une cible), ni sur
|
||||
# le personnage "joueur" lui-même (ce sont TOUJOURS les
|
||||
# AUTRES objets qui réagissent à SA présence, jamais
|
||||
# l'inverse).
|
||||
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
||||
o["collision_rules"] = resolve_collision_rules(o)
|
||||
payload_screens.append({**s, "elements": objects})
|
||||
|
||||
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
||||
# écrans, écrans-modèles compris (include_templates=True) — pas
|
||||
# seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable
|
||||
# (ex. "mail content") peut avoir son propre déclencheur/sa propre
|
||||
# animation posée dans l'éditeur de SON écran-modèle (voir
|
||||
# screen_edit.html) : sans ses entrées ici, gameData.flows/
|
||||
# gameData.animations n'auraient jamais contenu cet écran-modèle côté
|
||||
# client, et findTriggerNode()/collectAnimationClips() (templates/
|
||||
# play.html) — pourtant déjà écrits pour les chercher — n'auraient
|
||||
# jamais rien trouvé. On ne les ajoute PAS à payload_screens : un
|
||||
# écran-modèle ne doit jamais être rendu comme un vrai <div
|
||||
# class="playScreen"> (il n'est jamais affiché tel quel, seulement
|
||||
# rechargé en direct à l'intérieur d'un élément qui l'utilise).
|
||||
# Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
|
||||
# écrans. Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
|
||||
# affichage de la page de jeu (donc toujours à partir du personnage
|
||||
# Forge ACTUELLEMENT assigné), plutôt que figées dans le nœud de flow/
|
||||
# le clip de Timeline au moment où le créateur les a configurés : sans
|
||||
@@ -107,24 +88,17 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# voir runActionNode()/applyAnimationClip() qui font cette résolution
|
||||
# à l'exécution à partir de gameData.personnage_animations plutôt que
|
||||
# depuis un data_value/custom_keyframes qui contiendrait des frames
|
||||
# déjà résolues). include_templates=True : un personnage peut vivre
|
||||
# dans un écran-modèle d'élément de jeu réutilisable, comme les flows/
|
||||
# animations ci-dessous.
|
||||
# déjà résolues).
|
||||
personnage_animations = {}
|
||||
for s in list_screens(slug, include_templates=True):
|
||||
for s in screens_:
|
||||
flows[str(s["id"])] = {
|
||||
"nodes": list_flow_nodes(slug, s["id"]),
|
||||
"edges": list_flow_edges(slug, s["id"]),
|
||||
}
|
||||
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
|
||||
if s["kind"] == "jeu_2d":
|
||||
for o in list_scene_objects(slug, s["id"]):
|
||||
if o["kind"] == "personnage":
|
||||
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
||||
else:
|
||||
for el in list_elements(slug, s["id"]):
|
||||
if el.get("widget") == "personnage":
|
||||
personnage_animations[str(el["id"])] = resolve_personnage_animations(el)
|
||||
for o in list_scene_objects(slug, s["id"]):
|
||||
if o["kind"] == "personnage":
|
||||
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
||||
|
||||
definitions = db.list_definitions(slug)
|
||||
data = {}
|
||||
@@ -164,30 +138,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
for f in full["fields"]
|
||||
]
|
||||
|
||||
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
|
||||
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
|
||||
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
|
||||
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
|
||||
# aussi être joués à l'affichage de cet écran — sans quoi une animation
|
||||
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
|
||||
# modèle est utilisé ailleurs.
|
||||
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
|
||||
|
||||
# Arbre complet (éléments + descendants) de l'écran-modèle de chaque
|
||||
# élément de jeu réutilisable — voir screens/element_types/
|
||||
# load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu
|
||||
# créateur ET jeu publié en ligne). Nécessaire ici pour que le port
|
||||
# client-side de l'export Web/SCORM (static/js/play/offline/,
|
||||
# rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de
|
||||
# Répéteur) puisse le rejouer sans base de données — inoffensif pour
|
||||
# le runtime en ligne, qui l'ignore.
|
||||
element_type_templates = {}
|
||||
for type_id_str, template_screen_id in element_types.items():
|
||||
if template_screen_id:
|
||||
tree = _load_template_tree(slug, int(type_id_str))
|
||||
if tree:
|
||||
element_type_templates[type_id_str] = tree
|
||||
|
||||
# Instantané des variables globales, pour qu'un nœud Condition puisse en
|
||||
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
||||
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
|
||||
@@ -216,8 +166,8 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
|
||||
return {
|
||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||
"data": data, "fields_meta": fields_meta,
|
||||
"variables": variables, "personnage_animations": personnage_animations,
|
||||
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
||||
"definition_names": definition_names,
|
||||
"quests": quests,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -33,6 +33,7 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
kind = kind if kind in _KINDS else "personnage"
|
||||
attributes = {}
|
||||
width = height = None
|
||||
conn = db.connect(slug)
|
||||
if kind == "personnage":
|
||||
data = dict(_DEFAULT_PERSONNAGE_DATA)
|
||||
if forge_character:
|
||||
@@ -41,6 +42,16 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY:
|
||||
data["forge_character"] = forge_character
|
||||
attributes["_personnage_data"] = json.dumps(data)
|
||||
# Rôle "pnj" explicite (demande explicite : un personnage fraîchement
|
||||
# posé n'est JAMAIS le joueur par défaut, même le premier de la
|
||||
# scène — voir screens/rendering/personnage_role.py, "🏷️ Rôle" dans
|
||||
# le panneau de propriétés pour le passer en "Joueur" à la main).
|
||||
# Un rôle non défini retomberait sur "joueur" par défaut (voir
|
||||
# resolve_personnage_role) : sans cette affectation explicite, un
|
||||
# personnage fraîchement posé se ferait donc passer pour le joueur
|
||||
# et disparaîtrait à tort de "🧩 Collision" (voir scene_edit_view.py,
|
||||
# qui exclut toujours le joueur).
|
||||
attributes["_personnage_role"] = "pnj"
|
||||
elif kind == "fond" and background_slug:
|
||||
from ..labels.background_library import BACKGROUND_LIBRARY
|
||||
background = BACKGROUND_LIBRARY.get(background_slug)
|
||||
@@ -53,7 +64,6 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
width, height = _QUIZ_BOX_DEFAULT_SIZE
|
||||
elif kind == "score_widget":
|
||||
width, height = _SCORE_WIDGET_DEFAULT_SIZE
|
||||
conn = db.connect(slug)
|
||||
if kind == "fond":
|
||||
z_index = -1000
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
def resolve_scene_world_size(screen, objects):
|
||||
"""Taille du "monde" d'une scène 2D (voir static/js/play/personnage-
|
||||
controller.js::forgeUpdateSceneWorldSize, même principe côté serveur) :
|
||||
un "fond" posé à sa taille RÉELLE (souvent plus grande que la scène/
|
||||
caméra nominale, voir screens/scenes/add_scene_object.py) agrandit le
|
||||
monde d'autant — factorisé ici pour être identique partout où une
|
||||
scène est affichée à sa vraie taille : l'éditeur (routes/scenes/
|
||||
scene_edit_view.py) ET l'aperçu en carte de "Mes écrans" (routes/
|
||||
games/game_dashboard.py, sans quoi la miniature ne montrait que le
|
||||
coin haut-gauche de la scène nominale — jamais le fond entier — quand
|
||||
ce dernier dépasse)."""
|
||||
world_width, world_height = screen["scene_width"], screen["scene_height"]
|
||||
for o in objects:
|
||||
if o["kind"] == "fond":
|
||||
world_width = max(world_width, o["x"] + o["width"])
|
||||
world_height = max(world_height, o["y"] + o["height"])
|
||||
return int(world_width), int(world_height)
|
||||
@@ -3,7 +3,7 @@ import db
|
||||
from .ensure_schema import ensure_schema
|
||||
|
||||
|
||||
def create_screen(slug, name, is_template=False, kind="document"):
|
||||
def create_screen(slug, name, is_template=False, kind="jeu_2d"):
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
if is_template:
|
||||
|
||||
@@ -15,77 +15,8 @@ def ensure_schema(slug):
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS _element_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
icon TEXT NOT NULL DEFAULT '🧩',
|
||||
default_widget TEXT NOT NULL DEFAULT 'conteneur',
|
||||
default_content TEXT NOT NULL DEFAULT '',
|
||||
default_attributes TEXT NOT NULL DEFAULT '{}',
|
||||
default_style TEXT NOT NULL DEFAULT '{}',
|
||||
template_screen_id INTEGER REFERENCES _screens(id),
|
||||
bound_definition_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS _screen_elements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
|
||||
parent_id INTEGER REFERENCES _screen_elements(id) ON DELETE CASCADE,
|
||||
widget TEXT NOT NULL DEFAULT 'conteneur',
|
||||
element_type_id INTEGER REFERENCES _element_types(id),
|
||||
name TEXT,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
attributes TEXT NOT NULL DEFAULT '{}',
|
||||
x REAL NOT NULL DEFAULT 10,
|
||||
y REAL NOT NULL DEFAULT 10,
|
||||
width REAL NOT NULL DEFAULT 20,
|
||||
height REAL NOT NULL DEFAULT 10,
|
||||
z_index INTEGER NOT NULL DEFAULT 1,
|
||||
style TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS _actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
element_id INTEGER NOT NULL REFERENCES _screen_elements(id) ON DELETE CASCADE,
|
||||
trigger_event TEXT NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
target_screen_id INTEGER REFERENCES _screens(id),
|
||||
target_element_id INTEGER REFERENCES _screen_elements(id),
|
||||
element_property TEXT,
|
||||
element_value TEXT,
|
||||
target_definition_id INTEGER,
|
||||
target_row_id INTEGER,
|
||||
target_field TEXT,
|
||||
data_operation TEXT,
|
||||
data_value TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_screen_elements)").fetchall()}
|
||||
if "widget" not in cols:
|
||||
conn.execute("ALTER TABLE _screen_elements ADD COLUMN widget TEXT NOT NULL DEFAULT 'conteneur'")
|
||||
if "attributes" not in cols:
|
||||
conn.execute("ALTER TABLE _screen_elements ADD COLUMN attributes TEXT NOT NULL DEFAULT '{}'")
|
||||
if "parent_id" not in cols:
|
||||
conn.execute("ALTER TABLE _screen_elements ADD COLUMN parent_id INTEGER REFERENCES _screen_elements(id) ON DELETE CASCADE")
|
||||
if "name" not in cols:
|
||||
conn.execute("ALTER TABLE _screen_elements ADD COLUMN name TEXT")
|
||||
type_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_element_types)").fetchall()}
|
||||
if "default_widget" not in type_cols:
|
||||
conn.execute("ALTER TABLE _element_types ADD COLUMN default_widget TEXT NOT NULL DEFAULT 'conteneur'")
|
||||
conn.execute("ALTER TABLE _element_types ADD COLUMN default_content TEXT NOT NULL DEFAULT ''")
|
||||
conn.execute("ALTER TABLE _element_types ADD COLUMN default_attributes TEXT NOT NULL DEFAULT '{}'")
|
||||
if "template_screen_id" not in type_cols:
|
||||
# Un élément de jeu n'est plus un simple widget isolé : c'est un
|
||||
# conteneur avec un vrai arbre d'éléments imbriqués à l'intérieur,
|
||||
# construit exactement comme le contenu de n'importe quel conteneur
|
||||
# sur un écran — ce petit arbre vit dans un écran-modèle CACHÉ
|
||||
# (voir "is_template" ci-dessous), pointé par cette colonne.
|
||||
conn.execute("ALTER TABLE _element_types ADD COLUMN template_screen_id INTEGER REFERENCES _screens(id)")
|
||||
if "bound_definition_id" not in type_cols:
|
||||
# "Objet lié" optionnel — comme pour le Répéteur de données, rend
|
||||
# {{nom_du_champ}} disponible dans tout l'arbre de ce modèle.
|
||||
conn.execute("ALTER TABLE _element_types ADD COLUMN bound_definition_id INTEGER")
|
||||
screen_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_screens)").fetchall()}
|
||||
if "is_template" not in screen_cols:
|
||||
# Un écran-modèle héberge le contenu d'un élément de jeu réutilisable
|
||||
@@ -124,26 +55,10 @@ def ensure_schema(slug):
|
||||
conn.execute("ALTER TABLE _screens ADD COLUMN scene_width INTEGER NOT NULL DEFAULT 960")
|
||||
conn.execute("ALTER TABLE _screens ADD COLUMN scene_height INTEGER NOT NULL DEFAULT 540")
|
||||
if "kind" not in screen_cols:
|
||||
# Fusion des moteurs — "document" (écran %) ou "jeu_2d" (scène
|
||||
# pixels) devient une propriété PAR ÉCRAN plutôt que par projet
|
||||
# (voir db/games/get_game_type.py, désormais lu UNIQUEMENT ici,
|
||||
# pour la rétro-compatibilité) : un même jeu peut mélanger les
|
||||
# deux types d'écran librement (voir routes/screens/screen_edit.py,
|
||||
# qui dispatche maintenant sur screen["kind"]).
|
||||
conn.execute("ALTER TABLE _screens ADD COLUMN kind TEXT NOT NULL DEFAULT 'document'")
|
||||
# Un seul moteur d'écran désormais : "jeu_2d" (scène pixels — voir
|
||||
# db/games/get_game_type.py, conservé pour la rétro-compatibilité
|
||||
# d'un jeu créé avant l'existence de cette colonne).
|
||||
conn.execute("ALTER TABLE _screens ADD COLUMN kind TEXT NOT NULL DEFAULT 'jeu_2d'")
|
||||
conn.execute("UPDATE _screens SET kind = ?", (db.get_game_type(slug),))
|
||||
action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()}
|
||||
for col_name, col_def in [
|
||||
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
|
||||
("element_property", "TEXT"),
|
||||
("element_value", "TEXT"),
|
||||
("target_definition_id", "INTEGER"),
|
||||
("target_row_id", "INTEGER"),
|
||||
("target_field", "TEXT"),
|
||||
("data_operation", "TEXT"),
|
||||
("data_value", "TEXT"),
|
||||
]:
|
||||
if col_name not in action_cols:
|
||||
conn.execute(f"ALTER TABLE _actions ADD COLUMN {col_name} {col_def}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import db
|
||||
|
||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||
|
||||
|
||||
def screens_referencing(slug, screen_id):
|
||||
"""Écrans dont une action cible (aller_a) précisément cet écran — pour
|
||||
avertir avant suppression plutôt que de casser silencieusement le lien."""
|
||||
"""Écrans dont un nœud de logique (action "aller_a") cible précisément
|
||||
cet écran — pour avertir avant suppression plutôt que de casser
|
||||
silencieusement le lien."""
|
||||
ensure_flow_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT s.id, s.name FROM _actions a
|
||||
JOIN _screen_elements e ON e.id = a.element_id
|
||||
JOIN _screens s ON s.id = e.screen_id
|
||||
WHERE a.action_type = 'aller_a' AND a.target_screen_id = ?""",
|
||||
"""SELECT DISTINCT s.id, s.name FROM _flow_nodes n
|
||||
JOIN _screens s ON s.id = n.screen_id
|
||||
WHERE n.action_type = 'aller_a' AND n.target_screen_id = ?""",
|
||||
(screen_id,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import db
|
||||
|
||||
from .ensure_schema import ensure_schema
|
||||
|
||||
_ALLOWED = {"9/16", "16/9", "1/1"}
|
||||
|
||||
|
||||
def set_screen_aspect(slug, screen_id, aspect):
|
||||
"""Mémorise le format d'aperçu choisi (Portrait/Paysage/Carré) pour cet
|
||||
écran — voir le commentaire sur la colonne preview_aspect dans
|
||||
ensure_schema.py. Ignore silencieusement une valeur inconnue plutôt que
|
||||
de planter, au cas où le client enverrait autre chose que les trois
|
||||
boutons prévus."""
|
||||
if aspect not in _ALLOWED:
|
||||
return
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute("UPDATE _screens SET preview_aspect = ? WHERE id = ?", (aspect, screen_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
"""Catalogue de widgets et constructeurs de réglages ("controls")."""
|
||||
@@ -1 +0,0 @@
|
||||
BORDER_STYLE_OPTIONS = [("solid", "Trait plein"), ("dashed", "Tirets"), ("dotted", "Pointillés"), ("none", "Aucune")]
|
||||
@@ -1,14 +0,0 @@
|
||||
# Listes de modificateurs Bulma (https://bulma.io) réutilisées par plusieurs
|
||||
# widgets pour construire leurs réglages "class:xxx" (voir
|
||||
# control_groups/bulma_controls.py et save_element_controls.py) — un seul
|
||||
# endroit à mettre à jour si Bulma change de version/palette.
|
||||
BULMA_COLOR_OPTIONS = [
|
||||
("", "Aucune (couleur par défaut)"),
|
||||
("is-primary", "Primaire"), ("is-link", "Lien"), ("is-info", "Info"),
|
||||
("is-success", "Succès"), ("is-warning", "Attention"), ("is-danger", "Danger"),
|
||||
("is-white", "Blanc"), ("is-light", "Clair"), ("is-dark", "Sombre"), ("is-black", "Noir"), ("is-text", "Texte"),
|
||||
]
|
||||
|
||||
BULMA_SIZE_OPTIONS = [
|
||||
("", "Normale"), ("is-small", "Petite"), ("is-medium", "Moyenne"), ("is-large", "Grande"),
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
CONTAINER_WIDGETS = {"conteneur", "repeteur_donnees", "groupe_champs", "superposition"}
|
||||
@@ -1 +0,0 @@
|
||||
"""Groupes de réglages ("controls") réutilisés par plusieurs widgets."""
|
||||
@@ -1,20 +0,0 @@
|
||||
from ..controls.c_slider import c_slider
|
||||
from ..controls.c_color import c_color
|
||||
from ..controls.c_select import c_select
|
||||
from ..border_style_options import BORDER_STYLE_OPTIONS
|
||||
|
||||
# Les épaisseurs "par côté" ci-dessous sont volontairement placées APRÈS
|
||||
# "border-width" (tous les côtés) dans cette même liste : pour une même
|
||||
# propriété CSS, la déclaration qui apparaît EN DERNIER dans l'attribut
|
||||
# style l'emporte — un réglage par côté prime donc sur le réglage global
|
||||
# quand les deux sont utilisés ensemble. La couleur et le style de bordure
|
||||
# restent communs aux 4 côtés.
|
||||
BORDER_CONTROLS = [
|
||||
c_slider("border-width", "Épaisseur de la bordure, tous les côtés", "style:border-width", min=0, max=20, default=0, group="Bordure"),
|
||||
c_color("border-color", "Couleur de la bordure", "style:border-color", "#2b3140", group="Bordure"),
|
||||
c_select("border-style", "Style de la bordure", "style:border-style", BORDER_STYLE_OPTIONS, default="solid", group="Bordure"),
|
||||
c_slider("border-top-width", "Épaisseur — haut", "style:border-top-width", min=0, max=20, default=0, group="Bordure"),
|
||||
c_slider("border-right-width", "Épaisseur — droite", "style:border-right-width", min=0, max=20, default=0, group="Bordure"),
|
||||
c_slider("border-bottom-width", "Épaisseur — bas", "style:border-bottom-width", min=0, max=20, default=0, group="Bordure"),
|
||||
c_slider("border-left-width", "Épaisseur — gauche", "style:border-left-width", min=0, max=20, default=0, group="Bordure"),
|
||||
]
|
||||
@@ -1,65 +0,0 @@
|
||||
from ..controls.c_select import c_select
|
||||
from ..controls.c_checkbox import c_checkbox
|
||||
from ..bulma_variants import BULMA_COLOR_OPTIONS, BULMA_SIZE_OPTIONS
|
||||
|
||||
_GROUP = "Style Bulma"
|
||||
|
||||
# Chaque widget "natif Bulma" (voir fixed_attrs dans registry.py) expose ici
|
||||
# TOUTES les variantes que Bulma propose pour son composant — chacune ajoute
|
||||
# un jeton de classe indépendant (target "class:xxx", voir
|
||||
# save_element_controls.py et _visible_attrs.py) à la classe de base déjà
|
||||
# posée par fixed_attrs. Laissé à "" (par défaut) = aucun modificateur, look
|
||||
# Bulma nu. Les réglages de couleur/taille "libres" du widget (curseurs,
|
||||
# sélecteurs de couleur) restent utilisables par-dessus : un style en ligne
|
||||
# gagne toujours contre une classe CSS, donc personnaliser une couleur ou une
|
||||
# taille "à la main" continue de fonctionner exactement comme avant.
|
||||
|
||||
BULMA_BUTTON_CONTROLS = [
|
||||
c_select("bulma_couleur", "Couleur", "class:couleur", BULMA_COLOR_OPTIONS, default="", group=_GROUP),
|
||||
c_checkbox("bulma_nuance_claire", "Nuance claire (combinable avec une couleur)", "class:nuance", "is-light", "", group=_GROUP),
|
||||
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
|
||||
c_checkbox("bulma_arrondi", "Coins bien arrondis", "class:arrondi", "is-rounded", "", group=_GROUP),
|
||||
c_checkbox("bulma_contour", "Contour seulement (fond transparent)", "class:contour", "is-outlined", "", group=_GROUP),
|
||||
c_checkbox("bulma_inverse", "Couleurs inversées", "class:inverse", "is-inverted", "", group=_GROUP),
|
||||
c_checkbox("bulma_statique", "Statique (non cliquable visuellement)", "class:statique", "is-static", "", group=_GROUP),
|
||||
c_checkbox("bulma_chargement", "Indicateur de chargement", "class:chargement", "is-loading", "", group=_GROUP),
|
||||
c_checkbox("bulma_pleine_largeur", "Pleine largeur", "class:largeur", "is-fullwidth", "", group=_GROUP),
|
||||
]
|
||||
|
||||
BULMA_TITLE_CONTROLS = [
|
||||
c_select("bulma_taille", "Taille Bulma (indépendante du niveau de titre)", "class:taille",
|
||||
[("", "Automatique"), ("is-1", "1 — la plus grande"), ("is-2", "2"), ("is-3", "3"),
|
||||
("is-4", "4"), ("is-5", "5"), ("is-6", "6 — la plus petite")], default="", group=_GROUP),
|
||||
c_checkbox("bulma_espace", "Garder l'espacement même sans sous-titre", "class:espace", "is-spaced", "", group=_GROUP),
|
||||
]
|
||||
|
||||
BULMA_PROGRESS_CONTROLS = [
|
||||
c_select("bulma_taille", "Taille de la barre", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
|
||||
]
|
||||
|
||||
BULMA_TABLE_CONTROLS = [
|
||||
c_checkbox("bulma_bordure", "Bordures", "class:bordure", "is-bordered", "", default=True, group=_GROUP),
|
||||
c_checkbox("bulma_pleine_largeur", "Pleine largeur", "class:largeur", "is-fullwidth", "", default=True, group=_GROUP),
|
||||
c_checkbox("bulma_zebre", "Lignes zébrées", "class:zebre", "is-striped", "", group=_GROUP),
|
||||
c_checkbox("bulma_survol", "Ligne surlignée au survol", "class:survol", "is-hoverable", "", group=_GROUP),
|
||||
c_checkbox("bulma_compact", "Compact (cellules resserrées)", "class:compact", "is-narrow", "", group=_GROUP),
|
||||
]
|
||||
|
||||
BULMA_TABS_CONTROLS = [
|
||||
c_select("bulma_alignement", "Alignement", "class:alignement",
|
||||
[("", "Gauche (par défaut)"), ("is-centered", "Centré"), ("is-right", "Droite")], default="", group=_GROUP),
|
||||
c_select("bulma_style", "Style", "class:style",
|
||||
[("", "Simple (soulignement)"), ("is-boxed", "Encadré"), ("is-toggle", "Boutons accolés"),
|
||||
("is-toggle is-toggle-rounded", "Boutons accolés, arrondis")], default="", group=_GROUP),
|
||||
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
|
||||
c_checkbox("bulma_pleine_largeur", "Pleine largeur (onglets étirés)", "class:largeur", "is-fullwidth", "", group=_GROUP),
|
||||
]
|
||||
|
||||
# Champs de formulaire (input/textarea/select) : mêmes variantes que Bulma
|
||||
# propose pour .input/.textarea/.select.
|
||||
BULMA_FIELD_CONTROLS = [
|
||||
c_select("bulma_couleur", "Couleur", "class:couleur", BULMA_COLOR_OPTIONS, default="", group=_GROUP),
|
||||
c_select("bulma_taille", "Taille", "class:taille", BULMA_SIZE_OPTIONS, default="", group=_GROUP),
|
||||
c_checkbox("bulma_arrondi", "Coins bien arrondis", "class:arrondi", "is-rounded", "", group=_GROUP),
|
||||
c_checkbox("bulma_statique", "Statique (lecture seule visuelle)", "class:statique", "is-static", "", group=_GROUP),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
from ..controls.c_select import c_select
|
||||
from ..controls.c_clause_list import c_clause_list
|
||||
from ...flow.constants import CONDITION_OPERATORS
|
||||
|
||||
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
|
||||
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
|
||||
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
|
||||
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux
|
||||
# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU —
|
||||
# mêmes clauses, même moteur que le Répéteur de données, voir
|
||||
# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente
|
||||
# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet
|
||||
# "état de partie" qui ne garde qu'une seule ligne).
|
||||
DATA_BINDING_CONTROLS = [
|
||||
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
|
||||
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
|
||||
c_clause_list("data_filtres", "Trouver la ligne où...",
|
||||
clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur",
|
||||
operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
from ..controls.c_text import c_text
|
||||
|
||||
# 3.1 (Confort) — interactions au survol : mécanisme générique, disponible
|
||||
# sur TOUS les widgets (via UNIVERSAL_CONTROLS), pour révéler un texte
|
||||
# différent quand le joueur survole l'élément avec la souris — le cas
|
||||
# d'usage documenté : survoler un nom révèle l'adresse réelle, survoler un
|
||||
# lien révèle l'URL réelle. Laisser vide = aucun changement au survol (donc
|
||||
# aucune régression sur les jeux existants, dont l'attribut est absent).
|
||||
# Rendu : voir render_element_html.py (attribut data-hover-text) et
|
||||
# bindHoverTexts() dans templates/play.html (échange du texte au survol,
|
||||
# ignore volontairement les éléments qui ont des enfants — conteneurs — pour
|
||||
# ne jamais écraser une mise en page imbriquée).
|
||||
HOVER_CONTROLS = [
|
||||
c_text(
|
||||
"survol_texte",
|
||||
"Texte affiché au survol de la souris",
|
||||
"attr:_survol_texte",
|
||||
placeholder="ex. mathilde.d@forgebase.fr, https://vrai-lien.exemple...",
|
||||
group="Survol",
|
||||
),
|
||||
]
|
||||
@@ -1,44 +0,0 @@
|
||||
from ..controls.c_preset import c_preset
|
||||
from ..controls.c_checkbox import c_checkbox
|
||||
from ..controls.c_slider import c_slider
|
||||
|
||||
LAYOUT_CONTROLS = [
|
||||
c_preset(
|
||||
"disposition_interne", "Disposition interne",
|
||||
[
|
||||
("libre", "Empilement libre", {"display": "", "flex-direction": ""}),
|
||||
("ligne", "Alignés côte à côte", {"display": "flex", "flex-direction": "row"}),
|
||||
("ligne_inverse", "Alignés côte à côte, ordre inversé", {"display": "flex", "flex-direction": "row-reverse"}),
|
||||
("colonne", "Alignés les uns sous les autres", {"display": "flex", "flex-direction": "column"}),
|
||||
("colonne_inverse", "Alignés les uns sous les autres, ordre inversé", {"display": "flex", "flex-direction": "column-reverse"}),
|
||||
],
|
||||
default="libre", group="Disposition",
|
||||
),
|
||||
c_preset(
|
||||
"alignement_transversal", "Alignement transversal",
|
||||
[
|
||||
("etire", "Étirés pour remplir", {"align-items": ""}),
|
||||
("debut", "Alignés au début", {"align-items": "flex-start"}),
|
||||
("centre_t", "Centrés", {"align-items": "center"}),
|
||||
("fin", "Alignés à la fin", {"align-items": "flex-end"}),
|
||||
],
|
||||
default="etire", group="Disposition",
|
||||
),
|
||||
c_preset(
|
||||
"alignement_principal", "Répartition",
|
||||
[
|
||||
("debut_p", "Regroupés au début", {"justify-content": ""}),
|
||||
("centre_p", "Regroupés au centre", {"justify-content": "center"}),
|
||||
("fin_p", "Regroupés à la fin", {"justify-content": "flex-end"}),
|
||||
("espace_entre", "Espacement égal entre eux", {"justify-content": "space-between"}),
|
||||
("espace_autour", "Espacement égal autour de chacun", {"justify-content": "space-around"}),
|
||||
("espace_uniforme", "Espacement totalement uniforme", {"justify-content": "space-evenly"}),
|
||||
],
|
||||
default="debut_p", group="Disposition",
|
||||
),
|
||||
c_checkbox(
|
||||
"flex-wrap", "Autoriser le retour à la ligne si ça ne tient pas",
|
||||
"style:flex-wrap", true_value="wrap", false_value="nowrap", default=False, group="Disposition",
|
||||
),
|
||||
c_slider("gap", "Espace entre les éléments", "style:gap", min=0, max=40, default=8, group="Disposition"),
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
from ..controls.c_shadow import c_shadow
|
||||
|
||||
SHADOW_CONTROLS = [
|
||||
c_shadow("box-shadow", "Ombre portée", "style:box-shadow", group="Ombre"),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
from ..controls.c_size import c_size
|
||||
|
||||
# Taille d'un élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de
|
||||
# champs : par défaut, ces éléments remplissent la largeur disponible et
|
||||
# s'ajustent en hauteur à leur contenu (voir _style_string) — 0 garde ce
|
||||
# comportement automatique ; une valeur non nulle impose une taille fixe,
|
||||
# dans l'unité choisie par l'utilisateur (% du parent, qui suit
|
||||
# naturellement n'importe quelle taille d'écran — ou px, pour une taille
|
||||
# précise, ex: une image de 120px de large dans une ligne, pour que
|
||||
# "Espacement égal entre eux"/"autour"/"uniforme" sur le conteneur parent
|
||||
# ait un effet visible, ce qui n'est pas le cas tant que chaque élément
|
||||
# remplit 100% de la largeur).
|
||||
#
|
||||
# UN SEUL contrôle par axe (pas un slider % + un slider px séparés) : les
|
||||
# deux cibleraient la même propriété CSS "width"/"height", et le mécanisme
|
||||
# de save_element_controls.py qui préserve un réglage "jamais personnalisé"
|
||||
# ne protège que le PREMIER contrôle à écrire une clé donnée — un second
|
||||
# contrôle non touché par l'utilisateur mais ciblant la même clé
|
||||
# l'écraserait quand même à chaque sauvegarde (bug constaté en pratique).
|
||||
#
|
||||
# Ces réglages ne sont PAS rendus via le mécanisme générique de groupes du
|
||||
# panneau de propriétés (voir screen_edit.html) : ils sont affichés à la
|
||||
# main dans le bloc "Position & taille", à la place de X/Y (non applicables
|
||||
# à un élément qui suit le flux de son parent plutôt qu'une position
|
||||
# absolue) — voir selected_controls / size_ctrls dans le template.
|
||||
SIZE_OVERRIDE_CONTROLS = [
|
||||
c_size("child-width", "Largeur", "style:width", default=0, group="Position & taille"),
|
||||
c_size("child-height", "Hauteur", "style:height", default=0, group="Position & taille"),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
from ..controls.c_slider import c_slider
|
||||
|
||||
# Les réglages "par côté" sont volontairement placés APRÈS les réglages
|
||||
# globaux ci-dessous dans cette même liste : le style HTML final est généré
|
||||
# dans cet ordre, et pour une même propriété CSS (ex. padding-top vs
|
||||
# padding), la déclaration qui apparaît EN DERNIER dans l'attribut style
|
||||
# l'emporte — donc un réglage par côté prime bien sur le réglage global s'il
|
||||
# est utilisé en plus.
|
||||
SPACING_CONTROLS = [
|
||||
c_slider("padding", "Marge intérieure — tous les côtés", "style:padding", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("margin", "Marge extérieure — tous les côtés", "style:margin", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("padding-top", "Padding — haut", "style:padding-top", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("padding-right", "Padding — droite", "style:padding-right", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("padding-bottom", "Padding — bas", "style:padding-bottom", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("padding-left", "Padding — gauche", "style:padding-left", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("margin-top", "Margin — haut", "style:margin-top", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("margin-right", "Margin — droite", "style:margin-right", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("margin-bottom", "Margin — bas", "style:margin-bottom", min=0, max=60, default=0, group="Espacement"),
|
||||
c_slider("margin-left", "Margin — gauche", "style:margin-left", min=0, max=60, default=0, group="Espacement"),
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
from ..controls.c_select import c_select
|
||||
from ..font_options import FONT_OPTIONS
|
||||
|
||||
TEXT_STYLE_CONTROLS_EXTRA = [
|
||||
c_select("font-family", "Police de caractère", "style:font-family", FONT_OPTIONS, default="inherit", group="Texte"),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from ..controls.c_scale import c_scale
|
||||
from .visibility_control import VISIBILITY_CONTROL
|
||||
from .visibility_condition_controls import VISIBILITY_CONDITION_CONTROLS
|
||||
from .size_override_controls import SIZE_OVERRIDE_CONTROLS
|
||||
from .spacing_controls import SPACING_CONTROLS
|
||||
from .border_controls import BORDER_CONTROLS
|
||||
from .shadow_controls import SHADOW_CONTROLS
|
||||
|
||||
# Le panneau "Survol" (HOVER_CONTROLS) a été retiré du panneau de propriétés :
|
||||
# survoler un élément est conceptuellement un DÉCLENCHEUR (voir la Logique de
|
||||
# la scène), pas une propriété statique de l'élément — il sera réintroduit
|
||||
# là-bas. Le mécanisme data-hover-text sous-jacent (voir render_element_html
|
||||
# et bindHoverTexts() dans play.html) reste fonctionnel pour les éléments qui
|
||||
# en avaient déjà un, seul le moyen de le RÉGLER depuis ce panneau disparaît
|
||||
# pour l'instant.
|
||||
UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL, c_scale()] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + SHADOW_CONTROLS + VISIBILITY_CONDITION_CONTROLS
|
||||
@@ -1,34 +0,0 @@
|
||||
from ..controls.c_text import c_text
|
||||
from ..controls.c_select import c_select
|
||||
from ...flow.constants import CONDITION_OPERATORS
|
||||
|
||||
_GROUP = "Condition de visibilité"
|
||||
|
||||
# Disponible sur TOUT widget (voir UNIVERSAL_CONTROLS) — masque l'élément en
|
||||
# mode jouable (jamais dans l'éditeur, voir render_element_html.py) selon
|
||||
# soit une variable globale (db/global_vars/), soit le champ d'un objet de
|
||||
# données (convention "1 seule ligne = état de partie", déjà utilisée par la
|
||||
# Jauge/Donnée liée). "visibilite_cond_valeur" se termine par "_valeur" :
|
||||
# récupère automatiquement le sélecteur "Valeur fixe / Donnée d'un autre
|
||||
# objet" déjà construit pour les filtres du Répéteur (voir screen_edit.html).
|
||||
VISIBILITY_CONDITION_CONTROLS = [
|
||||
c_select("visibilite_cond_mode", "Afficher cet élément seulement si...", "attr:_visibilite_cond_mode",
|
||||
[("", "Toujours (pas de condition)"), ("variable", "Une variable globale..."), ("objet", "Un champ d'un objet...")],
|
||||
default="", group=_GROUP),
|
||||
c_select("visibilite_cond_variable", "Variable", "attr:_visibilite_cond_variable",
|
||||
[("", "(sélectionner une variable)")], default="", group=_GROUP),
|
||||
# Utile seulement si la variable choisie ci-dessus est de type
|
||||
# "objet"/"tableau" (db/constants.py) : sans chemin, c'est la variable
|
||||
# ENTIÈRE (son JSON tel quel) qui est comparée — voir
|
||||
# _resolve_variable_path dans filter_repeater_rows.py pour la syntaxe.
|
||||
c_text("visibilite_cond_variable_chemin", "Chemin dans la variable (optionnel)",
|
||||
"attr:_visibilite_cond_variable_chemin", placeholder="ex. .nom ou [0].valeur", default="", group=_GROUP),
|
||||
c_select("visibilite_cond_definition_id", "Objet", "attr:_visibilite_cond_definition_id",
|
||||
[("", "(sélectionner un objet)")], default="", group=_GROUP),
|
||||
c_select("visibilite_cond_champ", "Champ", "attr:_visibilite_cond_champ",
|
||||
[("", "(sélectionner un objet d'abord)")], default="", group=_GROUP),
|
||||
c_select("visibilite_cond_operateur", "...est", "attr:_visibilite_cond_operateur",
|
||||
CONDITION_OPERATORS, default="egal", group=_GROUP),
|
||||
c_text("visibilite_cond_valeur", "...cette valeur", "attr:_visibilite_cond_valeur",
|
||||
placeholder="ex. 1", default="", group=_GROUP),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from ..controls.c_preset import c_preset
|
||||
|
||||
VISIBILITY_CONTROL = c_preset(
|
||||
"visibilite", "Visibilité",
|
||||
[
|
||||
# "display" n'est volontairement pas touché par "visible" / "invisible" :
|
||||
# un conteneur en disposition ligne/colonne (flex) doit garder son
|
||||
# display:flex quand il redevient visible — seul "masqué" doit
|
||||
# explicitement forcer display:none (et gagne, car ce réglage est
|
||||
# appliqué après la disposition interne lors de l'enregistrement).
|
||||
("visible", "Visible normalement", {"visibility": ""}),
|
||||
("masque", "Masqué — n'occupe plus de place", {"display": "none", "visibility": ""}),
|
||||
("invisible", "Invisible — garde sa place", {"visibility": "hidden"}),
|
||||
],
|
||||
default="visible", group="Disposition",
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
import re
|
||||
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
|
||||
def _control_value(el, control):
|
||||
"""Lit la valeur actuelle d'un réglage à partir de l'élément stocké
|
||||
(content / attributs / style), avec repli sur sa valeur par défaut."""
|
||||
ctype = control["type"]
|
||||
style = el.get("style") or {}
|
||||
|
||||
if ctype == "clause_list":
|
||||
attrs = el.get("attributes") or {}
|
||||
clauses, combinateur = decode_clauses(
|
||||
attrs, control["clauses_attr"], control["combinator_attr"],
|
||||
legacy_prefix=control.get("legacy_prefix"),
|
||||
)
|
||||
return {"clauses": clauses, "combinateur": combinateur}
|
||||
|
||||
if ctype == "preset":
|
||||
# Trié par nombre de propriétés NON VIDES exigées, décroissant : une
|
||||
# option comme "Visible normalement" (visibility_control.py), dont
|
||||
# les critères ({"visibility": ""}) ne portent QUE sur "visibility"
|
||||
# (par choix assumé, pour ne jamais toucher "display" — voir son
|
||||
# commentaire), matchait sinon TOUJOURS en premier dès que
|
||||
# "visibility" est absent, MÊME quand "display:none" (posé par
|
||||
# "Masqué", un critère plus spécifique à 1 propriété non vide contre
|
||||
# 0 pour "Visible") est également présent -- le panneau affichait
|
||||
# alors "Visible normalement" sélectionné sur un élément en réalité
|
||||
# masqué, et comme un <select> ne redéclenche un enregistrement que
|
||||
# sur un VRAI changement de valeur, il devenait impossible de le
|
||||
# rendre visible depuis le panneau (choisir l'option déjà affichée
|
||||
# ne fait rien). Les options plus spécifiques (plus de critères non
|
||||
# vides) doivent donc toujours être vérifiées EN PREMIER.
|
||||
options = sorted(
|
||||
control["options"],
|
||||
key=lambda o: -sum(1 for v in o[2].values() if v),
|
||||
)
|
||||
for key, _label, props in options:
|
||||
if all((style.get(k) or "") == (v or "") for k, v in props.items()):
|
||||
return key
|
||||
return control.get("default")
|
||||
|
||||
if ctype == "toggle_token":
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
current = style.get(name, "") or ""
|
||||
return control["token"] in current.split()
|
||||
|
||||
if ctype == "scale":
|
||||
transform = style.get("transform", "") or ""
|
||||
m = re.search(r"scale\(([\d.]+)\)", transform)
|
||||
return float(m.group(1)) if m else control.get("default", 1)
|
||||
|
||||
if ctype == "size":
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
raw = style.get(name)
|
||||
m = re.match(r"^([\d.]+)(px|%)$", str(raw or ""))
|
||||
if m:
|
||||
return {"value": float(m.group(1)), "unit": m.group(2)}
|
||||
return {"value": control.get("default", 0), "unit": "%"}
|
||||
|
||||
if ctype == "shadow":
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
raw = style.get(name)
|
||||
m = re.match(
|
||||
r"^(-?\d+)px (-?\d+)px (\d+)px (\d+)px rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$",
|
||||
str(raw or ""),
|
||||
)
|
||||
if m:
|
||||
x, y, blur, spread, r, g, b, a = m.groups()
|
||||
return {
|
||||
"x": int(x), "y": int(y), "blur": int(blur), "spread": int(spread),
|
||||
"color": "#{:02x}{:02x}{:02x}".format(int(r), int(g), int(b)), "opacity": float(a),
|
||||
}
|
||||
return control.get("default", {})
|
||||
|
||||
target = control["target"]
|
||||
if target == "content":
|
||||
return el.get("content") or control.get("default", "")
|
||||
kind, name = target.split(":", 1)
|
||||
store = el.get("attributes") if kind == "attr" else style
|
||||
store = store or {}
|
||||
if ctype == "checkbox":
|
||||
if name not in store:
|
||||
return bool(control.get("default", False))
|
||||
return store[name] not in (None, "", control.get("false_value"))
|
||||
if ctype == "slider":
|
||||
raw = store.get(name)
|
||||
if raw is None:
|
||||
return control.get("default", 0)
|
||||
try:
|
||||
return float(str(raw).replace(control.get("unit", ""), "").strip() or 0)
|
||||
except ValueError:
|
||||
return control.get("default", 0)
|
||||
return store.get(name, control.get("default", ""))
|
||||
@@ -1 +0,0 @@
|
||||
"""Constructeurs de réglages ("controls") pour les widgets."""
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_align(key, label, target, default="left", group=None):
|
||||
return {"key": key, "label": label, "type": "align", "target": target, "default": default, "group": group}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_checkbox(key, label, target, true_value="1", false_value="", default=False, group=None):
|
||||
return {"key": key, "label": label, "type": "checkbox", "target": target, "true_value": true_value, "false_value": false_value, "default": default, "group": group}
|
||||
@@ -1,18 +0,0 @@
|
||||
def c_clause_list(key, label, clauses_attr, combinator_attr, operators, legacy_prefix=None, group=None):
|
||||
"""Réglage "liste de conditions" — un nombre ILLIMITÉ de clauses
|
||||
(champ/opérateur/valeur), combinées entre elles par ET ou par OU (voir
|
||||
aussi les nœuds Condition du graphe de logique, qui ont le même besoin
|
||||
— screens/flow/constants.py, cond_clauses/cond_combinator — même
|
||||
principe, appliqué ici à la Donnée liée d'un Texte/Titre).
|
||||
|
||||
Volontairement PAS de "target" (contrairement aux autres réglages) :
|
||||
stocké dans DEUX attributs séparés (clauses_attr : JSON de la liste ;
|
||||
combinator_attr : "et"/"ou"), traités par un code dédié à chaque étape
|
||||
(control_value.py, save_element_controls.py, controls_with_values.py,
|
||||
templates/screen_edit.html) plutôt que par le chemin générique "un
|
||||
réglage = une seule valeur = une seule clé de style/attribut"."""
|
||||
return {
|
||||
"type": "clause_list", "key": key, "label": label,
|
||||
"clauses_attr": clauses_attr, "combinator_attr": combinator_attr,
|
||||
"operators": operators, "legacy_prefix": legacy_prefix, "default": None, "group": group,
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_color(key, label, target, default="#e8eaf0", group=None):
|
||||
return {"key": key, "label": label, "type": "color", "target": target, "default": default, "group": group}
|
||||
@@ -1,3 +0,0 @@
|
||||
def c_preset(key, label, options, default=None, group=None):
|
||||
"""options: liste de (clé, libellé, {propriété_css: valeur, ...})."""
|
||||
return {"key": key, "label": label, "type": "preset", "options": options, "default": default or options[0][0], "group": group}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_scale(key="scale", label="Échelle", group="Disposition"):
|
||||
return {"key": key, "label": label, "type": "scale", "min": 0.1, "max": 3, "step": 0.05, "default": 1, "group": group}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_select(key, label, target, options, default=None, group=None):
|
||||
return {"key": key, "label": label, "type": "select", "target": target, "options": options, "default": default or options[0][0], "group": group}
|
||||
@@ -1,12 +0,0 @@
|
||||
def c_shadow(key, label, target, default=None, group=None):
|
||||
"""Ombre portée composée de 6 sous-réglages (décalage X/Y, flou,
|
||||
étendue, couleur, opacité) — voir save_element_controls.py (ctype
|
||||
"shadow") pour comment ils se combinent en une seule valeur CSS
|
||||
"box-shadow", et control_value.py pour comment elle se décompose à
|
||||
nouveau pour l'affichage. Décalages/flou/étendue tous à 0 = pas
|
||||
d'ombre, comme border-width à 0 = pas de bordure."""
|
||||
return {
|
||||
"key": key, "label": label, "type": "shadow", "target": target,
|
||||
"default": default or {"x": 0, "y": 4, "blur": 12, "spread": 0, "color": "#000000", "opacity": 0.4},
|
||||
"group": group,
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
def c_size(key, label, target, default=0, group=None):
|
||||
"""Taille avec unité choisie par l'utilisateur (% ou px) — voir
|
||||
size_override_controls.py. 0 = comportement automatique, quelle que soit
|
||||
l'unité affichée."""
|
||||
return {"key": key, "label": label, "type": "size", "target": target, "default": default, "group": group}
|
||||
@@ -1,11 +0,0 @@
|
||||
def c_slider(key, label, target, unit="px", min=0, max=100, step=1, default=16, group=None, no_freeze=False):
|
||||
return {
|
||||
"key": key, "label": label, "type": "slider", "target": target, "unit": unit,
|
||||
"min": min, "max": max, "step": step, "default": default, "group": group,
|
||||
# no_freeze : n'écrit PAS ce réglage en style au moment de la création
|
||||
# de l'élément malgré sa valeur par défaut non-vide (voir
|
||||
# default_style_and_attributes) — utilisé quand un widget porte déjà
|
||||
# une classe CSS externe (ex. Bulma) qui doit rester visible tant que
|
||||
# ce réglage n'a pas été explicitement personnalisé.
|
||||
"no_freeze": no_freeze,
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_text(key, label, target, placeholder="", default="", group=None):
|
||||
return {"key": key, "label": label, "type": "text", "target": target, "placeholder": placeholder, "default": default, "group": group}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_textarea(key, label, target, placeholder="", default="", group=None):
|
||||
return {"key": key, "label": label, "type": "textarea", "target": target, "placeholder": placeholder, "default": default, "group": group}
|
||||
@@ -1,2 +0,0 @@
|
||||
def c_toggle_token(key, label, target, token, group=None):
|
||||
return {"key": key, "label": label, "type": "toggle_token", "target": target, "token": token, "group": group}
|
||||
@@ -1,3 +0,0 @@
|
||||
def c_url(key, label, target, placeholder="https://...", default="", allow_upload=False, upload_accept="*/*", group=None):
|
||||
return {"key": key, "label": label, "type": "url", "target": target, "placeholder": placeholder, "default": default,
|
||||
"allow_upload": allow_upload, "upload_accept": upload_accept, "group": group}
|
||||
@@ -1,99 +0,0 @@
|
||||
import db
|
||||
|
||||
from .widget_meta import widget_meta
|
||||
from .control_value import _control_value
|
||||
from .data_definition_options import data_definition_options
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
|
||||
|
||||
def _field_label(f):
|
||||
"""Libellé affiché dans une liste déroulante de champs — précise l'objet
|
||||
visé pour un champ "relation" (ex. "parcour (→ parcours)"), pour qu'on
|
||||
sache ce qu'on choisit sans avoir à aller vérifier dans l'objet."""
|
||||
if f["type"] == "relation" and f.get("relation_definition_name"):
|
||||
return f"{f['name']} (→ {f['relation_definition_name']})"
|
||||
return f["name"]
|
||||
|
||||
|
||||
def controls_with_values(slug, el, template_definition_id=None):
|
||||
"""Pour le panneau de propriétés : la liste des réglages du widget de
|
||||
cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage
|
||||
"définition_id" du Répéteur de données (et de la Jauge) a besoin du jeu
|
||||
courant pour lister les objets disponibles — d'où le paramètre slug.
|
||||
|
||||
`template_definition_id` : l'objet lié à l'ÉLÉMENT DE JEU en cours
|
||||
d'édition (bound_definition_id, réglé à sa création — voir
|
||||
element_types.html), passé par screen_edit.py seulement quand on
|
||||
édite un écran-modèle qui en a un. Sert de repli pour le champ
|
||||
"Contenu" (voir plus bas) : si CE widget n'a pas sa propre "Donnée
|
||||
liée" réglée, ses {{champ}} font quand même référence à l'objet lié
|
||||
de l'élément de jeu (c'est lui qui fournira les valeurs une fois posé
|
||||
dans un Répéteur), donc le sélecteur de champ doit lister SES champs
|
||||
par défaut plutôt que de rester vide."""
|
||||
meta = widget_meta(el.get("widget"))
|
||||
attributes = el.get("attributes") or {}
|
||||
# Pour la Jauge : quel enregistrement suivre (row_id) et quels champs
|
||||
# afficher (champ/champ_nom) ne peuvent être proposés qu'une fois un
|
||||
# objet choisi (definition_id, déjà sur CET élément) — voir
|
||||
# render_jauge.py. La mise à jour EN DIRECT quand on change d'objet
|
||||
# sans quitter le panneau est gérée côté client (onJaugeDefinitionChange
|
||||
# dans screen_edit.html), ceci ne couvre que le premier affichage/une
|
||||
# navigation vers un élément déjà réglé.
|
||||
jauge_options = data_definition_options(slug, attributes.get("_definition_id"))
|
||||
# Le Texte/Titre lié à une donnée (voir DATA_BINDING_CONTROLS) garde SON
|
||||
# objet dans un attribut séparé (_data_definition_id) — jamais le même
|
||||
# élément que le Répéteur/la Jauge, donc jamais besoin des deux options
|
||||
# en même temps, mais un calcul dédié quand même pour rester correct si
|
||||
# ça change un jour.
|
||||
data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id"))
|
||||
# Repli pour "content" (voir plus bas) quand ce widget n'a pas sa
|
||||
# propre "Donnée liée" : les champs de l'objet lié à l'élément de jeu.
|
||||
template_bound_options = data_definition_options(slug, template_definition_id) if template_definition_id else None
|
||||
# Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) : elle
|
||||
# aussi garde SON objet dans un attribut séparé (_visibilite_cond_
|
||||
# definition_id), jamais partagé avec le Répéteur/la Jauge/Donnée liée.
|
||||
visibility_cond_options = data_definition_options(slug, attributes.get("_visibilite_cond_definition_id"))
|
||||
global_variables = db.list_global_variables(slug)
|
||||
result = []
|
||||
for control in meta.get("controls", []):
|
||||
control = dict(control)
|
||||
if control["key"] in ("definition_id", "data_definition_id", "visibilite_cond_definition_id"):
|
||||
defs = db.list_definitions(slug)
|
||||
control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs]
|
||||
if control["key"] == "visibilite_cond_champ":
|
||||
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in visibility_cond_options["fields"]]
|
||||
if control["key"] == "visibilite_cond_variable":
|
||||
control["options"] = [("", "(sélectionner une variable)")] + [(v["name"], v["name"]) for v in global_variables]
|
||||
if control["key"] == "template_type_id":
|
||||
types = list_element_types(slug)
|
||||
control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types]
|
||||
if control["key"] == "row_id":
|
||||
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(str(r["id"]), r["label"]) for r in jauge_options["rows"]]
|
||||
if control["key"] in ("champ", "champ_nom"):
|
||||
# La Jauge affiche une VALEUR (champ) ou un NOM (champ_nom) tel
|
||||
# quel — un champ "relation" n'y a pas de sens (c'est un id, pas
|
||||
# une valeur à afficher), donc exclu ici uniquement.
|
||||
non_relation_fields = [f for f in jauge_options["fields"] if f["type"] != "relation"]
|
||||
empty_label = "— aucun —" if control["key"] == "champ_nom" else "(sélectionner un objet d'abord)"
|
||||
control["options"] = [("", empty_label)] + [(f["name"], _field_label(f)) for f in non_relation_fields]
|
||||
if control["key"] in ("filtre_champ", "filtre2_champ"):
|
||||
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in jauge_options["fields"]]
|
||||
if control["type"] == "clause_list":
|
||||
# Options du sélecteur "champ" de CHAQUE ligne de condition —
|
||||
# les mêmes champs que data_filtre_champ ci-dessus (même objet
|
||||
# lié, attr:_data_definition_id), exposées une seule fois ici
|
||||
# plutôt que dupliquées par ligne (nombre de lignes variable).
|
||||
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
|
||||
if control["key"] == "content":
|
||||
# Une fois "Lier à un objet de données" réglé (voir
|
||||
# DATA_BINDING_CONTROLS), propose les champs de CET objet en
|
||||
# liste déroulante à côté du texte — plutôt que de devoir taper
|
||||
# "{{nom_du_champ}}" à la main sans savoir quels noms existent
|
||||
# (voir insertFieldAtCursor() dans screen_edit.html). À défaut,
|
||||
# retombe sur l'objet lié à l'élément de jeu (voir docstring).
|
||||
if attributes.get("_data_definition_id"):
|
||||
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
|
||||
elif template_bound_options:
|
||||
control["field_options"] = [(f["name"], _field_label(f)) for f in template_bound_options["fields"]]
|
||||
result.append({**control, "value": _control_value(el, control)})
|
||||
return result
|
||||
@@ -1,123 +0,0 @@
|
||||
import json
|
||||
|
||||
from .widget_meta import widget_meta
|
||||
|
||||
# Réglages "par côté" (padding/margin/bordure individuels) : contrairement
|
||||
# aux réglages "tous les côtés" équivalents (qui, eux, DOIVENT être figés à
|
||||
# la création — voir plus bas), ceux-ci ne doivent jamais l'être. Une fois
|
||||
# figés (donc présents dans le style stocké), save_element_controls.py les
|
||||
# réécrirait à CHAQUE enregistrement du panneau (puisqu'il envoie tous les
|
||||
# réglages en un seul formulaire) — et comme ils sont placés APRÈS le
|
||||
# réglage global correspondant dans les listes de contrôles (pour pouvoir le
|
||||
# surcharger quand on s'en sert), une valeur par défaut ainsi figée à 0
|
||||
# écraserait alors silencieusement tout changement fait sur le réglage
|
||||
# global (ex: "Épaisseur de la bordure" à 5px se retrouverait avec un côté
|
||||
# resté à 0px). Ils doivent donc rester ABSENTS du style tant qu'on ne les a
|
||||
# pas vraiment personnalisés.
|
||||
_PER_SIDE_KEYS = {
|
||||
"padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||
"margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||
"border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
|
||||
}
|
||||
|
||||
# Réglages "tous les côtés" dont la valeur par défaut est 0 (donc PAS filtrée
|
||||
# par "default in (None, '')" ci-dessous) : figés à la création pour tout
|
||||
# widget "nu" (reset volontaire, indispensable sur un canevas en positionnement
|
||||
# libre — sans lui, chaque <h1>/<ul>/<p> arriverait avec les marges/paddings
|
||||
# par défaut du navigateur). Mais pour un widget qui porte déjà une classe
|
||||
# externe (ex. Bulma "button"/"box" — voir fixed_attrs dans registry.py), ce
|
||||
# reset à 0 écraserait en permanence le padding/la bordure propres à cette
|
||||
# classe (un bouton Bulma sans padding ne ressemble plus à un bouton) : on ne
|
||||
# les fige donc PAS pour ces widgets-là, exactement comme les couleurs sont
|
||||
# déjà exclues plus bas pour ne jamais imposer une valeur non demandée.
|
||||
_ZERO_DEFAULT_RESET_KEYS = {"padding", "margin", "border-width"}
|
||||
|
||||
|
||||
def default_style_and_attributes(widget_key):
|
||||
"""Calcule le style/attributs de DÉPART d'un élément fraîchement posé sur
|
||||
l'écran, pour les réglages dont la valeur par défaut a un effet visuel
|
||||
voulu dès la création (ex: "Ajustement dans son cadre" = cover pour une
|
||||
image, afin qu'elle se recadre proprement sans être écrasée) plutôt que
|
||||
de rester une simple valeur d'aperçu dans le panneau de propriétés.
|
||||
|
||||
Les couleurs (fond, bordure) sont volontairement EXCLUES : leur valeur
|
||||
"par défaut" n'est qu'une suggestion affichée dans le sélecteur de
|
||||
couleur, pas un vrai réglage neutre (le neutre CSS est transparent) — la
|
||||
figer dès la création ferait apparaître une couleur que personne n'a
|
||||
demandée. Voir save_element_controls.py pour la même règle appliquée
|
||||
aux modifications ultérieures via le panneau de propriétés."""
|
||||
meta = widget_meta(widget_key)
|
||||
is_native_class_widget = bool(meta.get("fixed_attrs", {}).get("class"))
|
||||
attributes, style = {}, {}
|
||||
for control in meta.get("controls", []):
|
||||
ctype = control["type"]
|
||||
if ctype == "checkbox":
|
||||
# Une case cochée PAR DÉFAUT (ex: "Première ligne = en-tête" du
|
||||
# Tableau, ou un modificateur Bulma comme "is-bordered" activé
|
||||
# d'origine) doit vraiment être active dès la création — sinon le
|
||||
# panneau de propriétés affiche une case cochée qui ne correspond
|
||||
# à rien de réellement posé sur l'élément. Une case décochée par
|
||||
# défaut, elle, reste volontairement absente (comportement
|
||||
# existant, inchangé) : rien à figer.
|
||||
if control.get("default") is True and control.get("target"):
|
||||
kind, name = control["target"].split(":", 1)
|
||||
if kind == "class":
|
||||
attributes["_class_" + name] = control["true_value"]
|
||||
elif kind == "attr":
|
||||
attributes[name] = control["true_value"]
|
||||
else:
|
||||
style[name] = control["true_value"]
|
||||
continue
|
||||
if ctype in ("toggle_token", "preset", "scale", "color"):
|
||||
continue
|
||||
if ctype == "size":
|
||||
# 0 est TOUJOURS la valeur sentinelle "automatique" pour ce
|
||||
# type de réglage (voir size_override_controls.py) — jamais une
|
||||
# vraie valeur par défaut à figer à la création.
|
||||
continue
|
||||
if ctype == "shadow":
|
||||
# "default" est un dict composite (voir c_shadow.py), pas une
|
||||
# valeur CSS directement écrivable — sans cette exclusion, il
|
||||
# était écrit TEL QUEL (repr Python d'un dict) dans le style de
|
||||
# TOUT élément fraîchement créé, une valeur CSS invalide qui
|
||||
# cassait le "box-shadow" de tout élément neuf. Comme pour
|
||||
# "color" ci-dessus, la valeur par défaut n'est qu'une
|
||||
# suggestion affichée dans le panneau, pas un réglage neutre à
|
||||
# figer à la création (le neutre CSS est "pas d'ombre").
|
||||
continue
|
||||
if is_native_class_widget and control["key"] in _ZERO_DEFAULT_RESET_KEYS:
|
||||
continue
|
||||
target = control.get("target")
|
||||
if not target or target == "content":
|
||||
continue
|
||||
default = control.get("default", "")
|
||||
if default in (None, ""):
|
||||
continue
|
||||
if ctype == "slider":
|
||||
if control["key"] in _PER_SIDE_KEYS or control.get("no_freeze"):
|
||||
continue # réglage par côté, ou explicitement non figé (voir c_slider) : jamais figé
|
||||
unit = control.get("unit", "")
|
||||
default = f"{default}{unit}"
|
||||
kind, name = target.split(":", 1)
|
||||
(attributes if kind == "attr" else style)[name] = default
|
||||
|
||||
if widget_key == "superposition":
|
||||
# 3.4 (Confort) — overlay/modale réutilisable : contrairement aux
|
||||
# autres widgets, une Superposition démarre MASQUÉE par défaut dès
|
||||
# sa création (position:fixed; inset:0 la ferait sinon couvrir tout
|
||||
# l'écran immédiatement, avant même qu'une action l'affiche). Le
|
||||
# créateur du jeu doit poser une action "Modifier un élément →
|
||||
# Visibilité → Rendre visible" pour l'ouvrir — cohérent avec
|
||||
# "fermeture manuelle uniquement".
|
||||
style["display"] = "none"
|
||||
|
||||
if widget_key == "personnage":
|
||||
# Phase 8 — un personnage fraîchement posé est DÉJÀ configuré avec
|
||||
# un personnage Forge par défaut (voir screens/rendering/
|
||||
# personnage_data.py) plutôt que de démarrer vide : il s'affiche et
|
||||
# s'anime immédiatement dans l'éditeur (voir static/js/screen_edit/
|
||||
# personnage-preview.js), jamais une image cassée qu'il faudrait
|
||||
# d'abord configurer. Le tuile de galerie cliquée (voir element_add.py)
|
||||
# remplace ce défaut par le personnage réellement choisi juste après.
|
||||
attributes["_personnage_data"] = json.dumps({"source": "forge", "forge_character": "male-adventurer"})
|
||||
return attributes, style
|
||||
@@ -1,10 +0,0 @@
|
||||
FONT_OPTIONS = [
|
||||
("inherit", "Police par défaut"),
|
||||
("Georgia, serif", "Georgia (classique, avec empattements)"),
|
||||
("'Courier New', monospace", "Courier (machine à écrire)"),
|
||||
("'Trebuchet MS', sans-serif", "Trebuchet (arrondie)"),
|
||||
("'Poppins', sans-serif", "Poppins (Google Fonts, moderne)"),
|
||||
("'Roboto', sans-serif", "Roboto (Google Fonts, neutre)"),
|
||||
("'Montserrat', sans-serif", "Montserrat (Google Fonts, affirmée)"),
|
||||
("'Playfair Display', serif", "Playfair Display (Google Fonts, élégante)"),
|
||||
]
|
||||
@@ -1,47 +0,0 @@
|
||||
# Sélection large mais CURATÉE d'icônes Font Awesome 5 Free (style "solid"),
|
||||
# chacune un fichier SVG téléchargé une fois pour toutes dans static/icons/
|
||||
# (voir render_icone.py — affichées en silhouette via "mask-image", PAS une
|
||||
# police d'icônes : certains navigateurs/extensions bloquent le chargement
|
||||
# de polices web tierces par protection anti-fingerprinting, ce qui rendait
|
||||
# les icônes invisibles quelle que soit leur source — un SVG posé en masque
|
||||
# n'est jamais concerné). Volontairement PAS le catalogue Font Awesome 5
|
||||
# Free complet (~1500 icônes) : chaque entrée ici doit avoir un fichier SVG
|
||||
# réellement présent dans static/icons/, donc la liste reste bornée à ce qui
|
||||
# a été téléchargé plutôt que de deviner l'exhaustif.
|
||||
FONTAWESOME_ICONS = [
|
||||
"star", "heart", "check", "times", "plus", "minus", "search", "cog", "cogs", "wrench",
|
||||
"hammer", "tools", "trash", "trash-alt", "edit", "pen", "pencil-alt", "save", "download",
|
||||
"upload", "share", "share-alt", "link", "unlink", "lock", "lock-open", "key", "eye",
|
||||
"eye-slash", "bell", "bell-slash", "bookmark", "flag", "flag-checkered", "thumbtack",
|
||||
"filter", "sort", "sort-up", "sort-down", "list", "list-ul", "list-ol", "th", "th-list",
|
||||
"th-large", "bars", "ellipsis-h", "ellipsis-v", "expand", "compress", "sync", "sync-alt",
|
||||
"redo", "undo", "home", "user", "users", "user-circle", "user-plus", "user-minus",
|
||||
"user-friends", "sign-in-alt", "sign-out-alt", "question-circle", "info-circle",
|
||||
"exclamation-circle", "exclamation-triangle", "times-circle", "check-circle", "ban",
|
||||
"shield-alt", "gem", "coins", "money-bill", "wallet", "credit-card", "gift", "trophy",
|
||||
"medal", "award", "crown", "dice", "dice-d6", "dice-d20", "chess", "chess-king",
|
||||
"chess-queen", "chess-knight", "chess-bishop", "chess-rook", "chess-pawn", "puzzle-piece",
|
||||
"gamepad", "rocket", "bomb", "fire", "bolt", "snowflake", "sun", "moon", "cloud",
|
||||
"cloud-rain", "wind", "umbrella", "tree", "leaf", "seedling", "paw", "spider", "skull",
|
||||
"skull-crossbones", "map", "map-marker-alt", "map-marked-alt", "compass", "mountain",
|
||||
"water", "globe", "globe-americas", "flask", "vial", "syringe", "pills", "heartbeat",
|
||||
"dumbbell", "running", "walking", "swimmer", "futbol", "basketball-ball", "volleyball-ball",
|
||||
"football-ball", "baseball-ball", "table-tennis", "bowling-ball", "horse", "dove", "cat",
|
||||
"dog", "fish", "frog", "hippo", "otter", "kiwi-bird", "book", "book-open", "graduation-cap",
|
||||
"school", "chalkboard", "chalkboard-teacher", "pencil-ruler", "ruler", "ruler-combined",
|
||||
"calculator", "laptop", "desktop", "mobile-alt", "tablet-alt", "keyboard", "mouse",
|
||||
"headphones", "microphone", "video", "camera", "image", "images", "film", "music", "play",
|
||||
"pause", "stop", "step-forward", "step-backward", "volume-up", "volume-down", "volume-mute",
|
||||
"volume-off", "envelope", "envelope-open", "comment", "comments", "comment-dots", "phone",
|
||||
"phone-alt", "calendar", "calendar-alt", "calendar-check", "clock", "hourglass",
|
||||
"hourglass-half", "stopwatch", "car", "car-side", "truck", "plane", "plane-departure",
|
||||
"ship", "anchor", "subway", "train", "bus", "bicycle", "taxi", "road", "traffic-light",
|
||||
"gas-pump", "screwdriver", "toolbox", "hard-hat", "building", "city", "industry",
|
||||
"warehouse", "store", "shopping-cart", "shopping-bag", "box", "boxes", "dolly", "pallet",
|
||||
"archive", "folder", "folder-open", "file", "file-alt", "file-pdf", "file-word",
|
||||
"file-excel", "file-image", "file-video", "file-audio", "database", "server",
|
||||
"network-wired", "wifi", "signal", "satellite", "satellite-dish", "robot", "microchip",
|
||||
"memory", "hdd", "print", "cut", "paste", "copy", "clipboard", "clipboard-list",
|
||||
"clipboard-check", "sticky-note", "paperclip", "paint-brush", "palette", "magic",
|
||||
"fire-extinguisher", "first-aid", "briefcase", "suitcase",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user