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

Le document de cadrage produit cible des formateurs non techniques créant
des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de
logique en nœuds, timeline d'animation, définitions d'objets/relations,
templates réutilisables) est une complexité hors cible que l'effort
d'ingénierie récent avait déjà abandonnée au profit du jeu_2d.

- Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire
  Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only)
- Suppression en bloc des modules exclusifs au document : routes/elements,
  routes/element_types, routes/objects, routes/legacy_actions,
  screens/elements, screens/element_types, screens/widgets,
  screens/legacy_actions, le rendu render_element_html.py et son cluster,
  templates/screen_edit.html, templates/game_dashboard.html,
  flow-editor.js/tabs-and-blocks.js/animation-timeline.js
- Dashboard toujours simplifié (un seul mode possible désormais)
- Tests document-only supprimés, tests de logique partagée (flow,
  événements personnalisés, animations) retargetés sur des écrans jeu_2d
- Aucune régression jeu_2d : 299 tests passent

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