Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées.
This commit is contained in:
@@ -37,3 +37,7 @@ assets/background/
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Documents internes/business (cadrage produit...) et état local de
|
||||
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
|
||||
.claude/
|
||||
|
||||
@@ -33,4 +33,12 @@ def _open_browser():
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Timer(1.0, _open_browser).start()
|
||||
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False)
|
||||
# threaded=True : le serveur de dev Werkzeug traite UNE requête à la
|
||||
# fois par défaut — un jeu 2D charge des dizaines d'images (sprites de
|
||||
# personnages, fonds...) en parallèle à chaque chargement de page, et
|
||||
# sans ce réglage elles se mettaient en file d'attente une par une
|
||||
# (bug remonté : un Ctrl+F5, qui recharge TOUT sans cache, laissait
|
||||
# l'éditeur de scène "vide" le temps que les images finissent par
|
||||
# arriver l'une après l'autre — un rechargement normal, servi surtout
|
||||
# depuis le cache navigateur, le cachait).
|
||||
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from filters.colname_filter import _colname
|
||||
from filters.element_style_filter import _element_style, _element_transform_style
|
||||
from filters.truncate_sentences_filter import _truncate_sentences
|
||||
|
||||
app.jinja_env.filters["colname"] = _colname
|
||||
app.jinja_env.filters["elstyle"] = _element_style
|
||||
app.jinja_env.filters["eltransform"] = _element_transform_style
|
||||
app.jinja_env.filters["elabel"] = screens.element_label
|
||||
app.jinja_env.filters["truncatesentences"] = _truncate_sentences
|
||||
|
||||
@@ -15,48 +15,45 @@ rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
|
||||
routes/screens/screens_new.py)."""
|
||||
import db
|
||||
|
||||
DEFAULT_ONBOARDING_TYPE = "custom"
|
||||
DEFAULT_ONBOARDING_TYPE = "rpg"
|
||||
|
||||
ONBOARDING_TYPES = {
|
||||
"quiz": {
|
||||
"label": "Quiz",
|
||||
"tagline": "Questions, réponses, score.",
|
||||
"icon": "🎯",
|
||||
"description": "Gérer des questions/réponses, avec score.",
|
||||
"screen_kind": "document",
|
||||
"restricted": True,
|
||||
},
|
||||
"embranchement": {
|
||||
"label": "Jeu à embranchement / escape game",
|
||||
"tagline": "Chaque choix a une conséquence.",
|
||||
"icon": "🗝️",
|
||||
"description": (
|
||||
"Former à travers le jeu sur des concepts métier avec des "
|
||||
"conséquences observables ; réaliser des escape games sous "
|
||||
"forme d'énigmes."
|
||||
),
|
||||
"screen_kind": "document",
|
||||
"restricted": True,
|
||||
},
|
||||
"rpg": {
|
||||
"label": "Jeu RPG",
|
||||
"label": "Créer un jeu 2D ludique",
|
||||
"tagline": "Ton monde, ton héros, ton scénario.",
|
||||
"icon": "🗺️",
|
||||
"description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.",
|
||||
# Argumentaire pour un public RH non technique (voir .claude/
|
||||
# Forge_Engine_Cadrage.pdf) : chaque point correspond à une
|
||||
# fonctionnalité RÉELLEMENT construite (assistant "+ Action" de
|
||||
# l'éditeur de collision pour les interactions, "❓ Boîte à quiz"/
|
||||
# "🏆 Score" posables comme n'importe quel objet de scène, dialogue
|
||||
# de quête par état "Nouvelle/En cours/Terminée", export SCORM 1.2
|
||||
# natif), jamais une promesse. Du HTML de confiance (jamais de
|
||||
# saisie utilisateur ici) — rendu via `|safe` dans
|
||||
# templates/onboarding/onboarding_new.html, stylé par
|
||||
# .onboardingPitch* dans static/style.css : liste à coche + badge
|
||||
# LMS, pensé pour convaincre en un coup d'œil plutôt qu'un pavé de
|
||||
# texte brut.
|
||||
"description": (
|
||||
'<span class="onboardingPitchTitle">Un jeu 2D ludique,'
|
||||
" sans une ligne de code</span>"
|
||||
'<ul class="onboardingPitchList">'
|
||||
'<li><span class="onboardingPitchCheck">✓</span>'
|
||||
"<span><strong>Personnages vivants</strong> — posez-les, donnez-leur"
|
||||
" un rôle, ils bougent et réagissent seuls</span></li>"
|
||||
'<li><span class="onboardingPitchCheck">✓</span>'
|
||||
"<span><strong>Quiz animés</strong> — intégrés à la scène,"
|
||||
" correction immédiate</span></li>"
|
||||
'<li><span class="onboardingPitchCheck">✓</span>'
|
||||
"<span><strong>Score & dialogues</strong> — évoluent avec"
|
||||
" chaque quête</span></li>"
|
||||
"</ul>"
|
||||
'<span class="onboardingPitchBadge">🎓 Compatible LMS —'
|
||||
" export SCORM en 1 clic</span>"
|
||||
),
|
||||
"screen_kind": "jeu_2d",
|
||||
"restricted": True,
|
||||
},
|
||||
"custom": {
|
||||
"label": "Créer mon jeu de A à Z",
|
||||
"tagline": "Toute la puissance du moteur, sans limite.",
|
||||
"icon": "⚡",
|
||||
"description": (
|
||||
"Pour les utilisateurs avec des notions de dev/jeu vidéo : "
|
||||
"accès à toute la puissance du moteur."
|
||||
),
|
||||
"screen_kind": "document",
|
||||
"restricted": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from ..connection import connect
|
||||
|
||||
DEFAULT_GAME_TYPE = "document"
|
||||
DEFAULT_GAME_TYPE = "jeu_2d"
|
||||
|
||||
|
||||
def get_game_type(slug):
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
def _element_style(el):
|
||||
"""Construit le style inline de POSITIONNEMENT (position/taille en % +
|
||||
empilement) du cadre qui porte un élément posé sur un écran. L'apparence
|
||||
de l'élément lui-même (couleurs, police, etc.) vit dans le HTML généré
|
||||
par screens.render_element_html — ce filtre ne gère que le placement,
|
||||
partagé par l'éditeur et le mode jouable.
|
||||
|
||||
Ce cadre (.canvasElement / .playElement) est aussi la cible des
|
||||
animations Animate.css (data-anim-target, voir screen_edit.html et
|
||||
play.html) : il ne porte donc PAS le "transform" (échelle/rotation) de
|
||||
l'élément, pour ne jamais entrer en collision avec le "transform" que
|
||||
Animate.css pilote lui-même pendant qu'une animation joue (Animate.css
|
||||
pose "animation-fill-mode: both", qui remplace entièrement la valeur de
|
||||
la propriété — un "transform" inline posé ici serait écrasé pendant, et
|
||||
pour une animation en boucle infinie, en permanence). Voir
|
||||
_element_transform_style, qui porte ce "transform" sur un cadre séparé
|
||||
(.canvasElementInner / .playElementInner), à la taille exacte de celui-ci
|
||||
(donc visuellement identique).
|
||||
|
||||
Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà
|
||||
x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999
|
||||
sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même
|
||||
"z-index:{z_index}" (le z-index de sa place dans le canevas, souvent
|
||||
petit), un élément positionné avec un z-index EXPLICITE (même sur ce
|
||||
cadre, pas sur son contenu) devient un NOUVEAU contexte d'empilement
|
||||
CSS — le z-index:9999 posé plus profond ne se comparait alors plus
|
||||
qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un
|
||||
frère ajouté après lui sur le canevas, qui se retrouvait affiché
|
||||
PAR-DESSUS le dialogue censé tout couvrir.
|
||||
|
||||
Première correction tentée (retirer aussi position/left/top/width/
|
||||
height ici) : régression dans l'ÉDITEUR — ce même cadre (.canvasElement)
|
||||
sert aussi de prise pour glisser-déposer/redimensionner CE widget dans
|
||||
le canevas (voir screen_edit.html), dont le calcul se base sur ses
|
||||
dimensions réelles ; sans position:absolute + left/top/width/height, ce
|
||||
cadre s'effondre à 0×0 (son contenu réel est en position:fixed, donc
|
||||
hors flux), le calcul division par zéro produit NaN, et
|
||||
JSON.stringify(NaN) donne "null" — element_geometry.py plantait alors
|
||||
sur float(None). left/top/width/height restent donc posés comme pour
|
||||
n'importe quel widget (l'éditeur continue de fonctionner normalement) :
|
||||
SEUL le z-index est omis pour ce widget précis. position:absolute SANS
|
||||
z-index explicite (donc z-index:auto) ne crée PAS de nouveau contexte
|
||||
d'empilement — le z-index:9999 posé plus profond se compare alors
|
||||
directement aux autres éléments de l'écran, et gagne toujours.
|
||||
|
||||
Même omission pour un exemplaire (element_type_id) d'un élément de jeu
|
||||
dont le modèle N'EST QU'une superposition (marqué "_overlay_only_instance"
|
||||
par list_elements.py) : son propre widget est "conteneur" par défaut
|
||||
(add_element.py), pas "superposition", mais son cadre entoure quand
|
||||
même une superposition rendue à l'intérieur (voir
|
||||
render_element_html.py/_render_element_type_children) — exactement le
|
||||
même risque de contexte d'empilement piégeant."""
|
||||
exempt_from_z_index = (
|
||||
el.get("widget") == "superposition" or el.get("_overlay_only_instance")
|
||||
)
|
||||
parts = [
|
||||
"position:absolute",
|
||||
f"left:{el['x']}%", f"top:{el['y']}%",
|
||||
f"width:{el['width']}%", f"height:{el['height']}%",
|
||||
]
|
||||
if not exempt_from_z_index:
|
||||
parts.append(f"z-index:{el['z_index']}")
|
||||
return "; ".join(parts) + ";"
|
||||
|
||||
|
||||
def _element_transform_style(el):
|
||||
"""Style inline de l'échelle/rotation d'un élément posé directement sur
|
||||
un écran — voir _element_style pour pourquoi ce "transform" est séparé
|
||||
du cadre de positionnement/animation. Ce cadre intermédiaire
|
||||
(.canvasElementInner / .playElementInner) fait exactement la même taille
|
||||
que le cadre extérieur (width/height:100%, voir static/style.css et
|
||||
play.html) : y poser le "transform" produit donc le même rendu visuel
|
||||
que s'il était sur le cadre extérieur, sans le risque de collision avec
|
||||
Animate.css."""
|
||||
transform = (el.get("style") or {}).get("transform")
|
||||
return f"transform:{transform};" if transform else ""
|
||||
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import element_types, element_type_edit, element_type_delete
|
||||
@@ -1,12 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/element-types/<int:element_type_id>/delete", methods=["POST"])
|
||||
def element_type_delete(slug, element_type_id):
|
||||
if screens.element_type_usage_count(slug, element_type_id) == 0:
|
||||
screens.delete_element_type(slug, element_type_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
@@ -1,16 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/element-types/<int:element_type_id>/edit", methods=["POST"])
|
||||
def element_type_edit(slug, element_type_id):
|
||||
"""Renomme un élément de jeu et/ou change l'objet auquel il est lié —
|
||||
son contenu (l'arbre d'éléments) se modifie séparément, via le lien
|
||||
"✏️ Modifier le contenu" qui ouvre l'écran-modèle dans l'éditeur normal."""
|
||||
name = request.form.get("name", "").strip() or None
|
||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
|
||||
def element_types(slug):
|
||||
"""La création/gestion d'un élément de jeu se fait désormais depuis
|
||||
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
|
||||
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
|
||||
création (barre de création repliable de cet onglet). Un GET direct
|
||||
redirige vers le tableau de bord."""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
icon = request.form.get("icon", "").strip() or "🧩"
|
||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||
if name:
|
||||
screens.create_element_type(slug, name, icon, bound_definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
@@ -1,14 +0,0 @@
|
||||
from . import (
|
||||
element_add,
|
||||
element_add_child,
|
||||
element_geometry,
|
||||
element_reorder,
|
||||
element_move_to_container,
|
||||
element_save,
|
||||
element_delete,
|
||||
element_delete_impact,
|
||||
element_duplicate,
|
||||
element_rename,
|
||||
element_tab_add,
|
||||
element_set_personnage_data,
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from core.sprite_gate import forbid_admin_only_character
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
|
||||
def element_add(slug, screen_id):
|
||||
widget = request.form.get("widget", "conteneur")
|
||||
from_catalogue = widget == "__catalogue__"
|
||||
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
||||
if from_catalogue and not element_type_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
if from_catalogue:
|
||||
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
widget = et["default_widget"] if et else "conteneur"
|
||||
new_id = screens.add_element(slug, screen_id, widget, element_type_id)
|
||||
icon_slug = request.form.get("icon_class")
|
||||
if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS:
|
||||
# Posé depuis la galerie d'icônes (voir le panneau de propriétés,
|
||||
# section "Icônes") : l'icône choisie remplace celle posée par
|
||||
# défaut à la création plutôt que de repartir d'une icône neutre que
|
||||
# l'utilisateur devrait ensuite changer manuellement.
|
||||
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
|
||||
if widget == "personnage":
|
||||
# Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement()
|
||||
# dans tree-panels.js) : remplace le personnage Forge par défaut
|
||||
# (male-adventurer, voir default_style_for_widget.py) par celui
|
||||
# réellement cliqué.
|
||||
forge_character = request.form.get("forge_character")
|
||||
if forge_character and forge_character in screens.SPRITE_LIBRARY:
|
||||
forbid_admin_only_character(forge_character)
|
||||
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||
@@ -1,25 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:parent_id>/children/add", methods=["POST"])
|
||||
def element_add_child(slug, parent_id):
|
||||
"""Ajoute un élément PHYSIQUEMENT à l'intérieur d'un conteneur, d'un
|
||||
répéteur de données ou d'un groupe de champs déjà posé sur l'écran."""
|
||||
parent = screens.get_element(slug, parent_id)
|
||||
if not parent:
|
||||
abort(404)
|
||||
widget = request.form.get("widget", "conteneur")
|
||||
from_catalogue = widget == "__catalogue__"
|
||||
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
||||
if from_catalogue and element_type_id:
|
||||
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
widget = et["default_widget"] if et else "conteneur"
|
||||
elif from_catalogue:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=parent_id))
|
||||
new_id = screens.add_element(slug, parent["screen_id"], widget, element_type_id, parent_id=parent_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=new_id))
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/delete", methods=["POST"])
|
||||
def element_delete(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
screen_id = element["screen_id"]
|
||||
parent_id = element.get("parent_id")
|
||||
screens.delete_element(slug, element_id)
|
||||
# Resélectionne le conteneur parent (s'il y en a un) après suppression,
|
||||
# plutôt que de laisser le panneau de droite vide : sinon, supprimer un
|
||||
# élément fait brusquement "sauter" l'éditeur vers un état sans rien de
|
||||
# sélectionné.
|
||||
if parent_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=parent_id))
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
@@ -1,16 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
|
||||
def element_delete_impact(slug, element_id):
|
||||
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
|
||||
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
|
||||
Logique de la scène (déclencheur/action) seraient aussi supprimés en
|
||||
même temps que l'élément — voir delete_element.py, qui les supprime
|
||||
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
|
||||
constraint failed", voir son commentaire)."""
|
||||
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
|
||||
def element_duplicate(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
new_id = screens.duplicate_element(slug, element_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
|
||||
@@ -1,20 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/geometry", methods=["POST"])
|
||||
def element_geometry(slug, element_id):
|
||||
"""Appelé en AJAX pendant/à la fin d'un glisser-déposer, d'un
|
||||
redimensionnement, ou d'une modification des champs Position & taille —
|
||||
ne touche qu'à la position/taille, aucun rechargement de page nécessaire
|
||||
pour que ça reste fluide."""
|
||||
data = request.get_json(force=True)
|
||||
screens.update_element_geometry(
|
||||
slug, element_id,
|
||||
x=float(data["x"]), y=float(data["y"]),
|
||||
width=float(data["width"]), height=float(data["height"]),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,21 +0,0 @@
|
||||
from flask import request, jsonify, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
|
||||
def element_move_to_container(slug, element_id):
|
||||
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
|
||||
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
|
||||
dessous — le fait passer enfant de ce conteneur, en dernière position
|
||||
(voir move_element_to_container, qui refuse aussi silencieusement les
|
||||
cas invalides : cible non-conteneur, ou cycle)."""
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
data = request.get_json(force=True)
|
||||
new_parent_id = int(data["parent_id"])
|
||||
screens.move_element_to_container(slug, element_id, new_parent_id)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/rename", methods=["POST"])
|
||||
def element_rename(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
screens.rename_element(slug, element_id, request.form.get("name", ""))
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask import request, jsonify, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
|
||||
def element_reorder(slug, element_id):
|
||||
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
|
||||
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
|
||||
des éléments d'un même conteneur — voir reorder_element."""
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
data = request.get_json(force=True)
|
||||
target_id = int(data["target_id"])
|
||||
position = data.get("position")
|
||||
if position not in ("before", "after"):
|
||||
abort(400)
|
||||
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,18 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/save", methods=["POST"])
|
||||
def element_save(slug, element_id):
|
||||
"""Un seul formulaire pour tout le panneau de propriétés : un champ par
|
||||
réglage du widget (texte, case à cocher, curseur, couleur...) — c'est
|
||||
screens.save_element_controls qui sait vers quel attribut ou quelle
|
||||
propriété CSS chaque réglage doit être traduit."""
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
screens.save_element_controls(slug, element_id, request.form)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from core.sprite_gate import forbid_admin_only_character
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
|
||||
def element_set_personnage_data(slug, element_id):
|
||||
"""Change le personnage Forge d'un élément déjà posé (galerie du
|
||||
panneau de propriétés), ou met à jour ses animations personnalisées
|
||||
(import de frames) — voir personnage-panel.js. Renvoie les animations
|
||||
résolues pour que le client relance immédiatement l'aperçu animé sans
|
||||
recharger la page (voir personnage-preview.js)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
el = screens.get_element(slug, element_id)
|
||||
if not el:
|
||||
return jsonify({"error": "élément introuvable"}), 404
|
||||
if body.get("source") == "forge" and body.get("forge_character"):
|
||||
forbid_admin_only_character(body["forge_character"])
|
||||
screens.set_personnage_data(slug, element_id, body)
|
||||
el = screens.get_element(slug, element_id)
|
||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
|
||||
def element_tab_add(slug, element_id):
|
||||
onglets = screens.get_element(slug, element_id)
|
||||
if not onglets:
|
||||
abort(404)
|
||||
screens.add_tab(slug, element_id, onglets["screen_id"])
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
|
||||
@@ -8,25 +8,12 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>")
|
||||
def game_dashboard(slug):
|
||||
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
|
||||
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
|
||||
accès direct à chaque élément — la barre de navigation du jeu (voir
|
||||
base.html) permet déjà de sauter directement vers chaque éditeur, cette
|
||||
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
|
||||
liens.
|
||||
|
||||
Onboarding guidé (voir db/games/game_type_catalog.py) : un compte
|
||||
"restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE —
|
||||
juste ses écrans, en cartes avec un aperçu de leur contenu, plus un
|
||||
bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le
|
||||
projet : "jeu_2d" pour un RPG, "document" pour les autres — voir
|
||||
routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/
|
||||
variables restent réservés à "Créer mon jeu de A à Z", la seule
|
||||
formule pensée pour la puissance complète du moteur."""
|
||||
"""Vue d'ensemble du jeu : juste ses écrans, en cartes avec un aperçu
|
||||
de leur contenu, plus un bouton "+ Ajouter un écran" qui ne demande pas
|
||||
de type (un seul moteur d'écran : jeu_2d — voir
|
||||
routes/screens/screens_new.py)."""
|
||||
game = db.game_meta(slug)
|
||||
if db.is_restricted(slug):
|
||||
return _render_simple_dashboard(slug, game)
|
||||
return _render_full_dashboard(slug, game)
|
||||
return _render_simple_dashboard(slug, game)
|
||||
|
||||
|
||||
def _render_simple_dashboard(slug, game):
|
||||
@@ -37,61 +24,17 @@ def _render_simple_dashboard(slug, game):
|
||||
objects = screens.list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
|
||||
else:
|
||||
# Taille du MONDE (voir screens.resolve_scene_world_size,
|
||||
# même calcul que l'éditeur, routes/scenes/scene_edit_view.py)
|
||||
# plutôt que scene_width/scene_height (la caméra nominale) :
|
||||
# un "fond" plus grand que la caméra dépassait alors le cadre
|
||||
# de la miniature (game_dashboard_simple.html), qui n'en
|
||||
# montrait que le coin haut-gauche au lieu de la scène entière.
|
||||
world_width, world_height = screens.resolve_scene_world_size(s, objects)
|
||||
previews[s["id"]] = {
|
||||
"kind": "document",
|
||||
"elements": screens.list_elements(slug, s["id"]),
|
||||
"kind": "jeu_2d", "objects": objects,
|
||||
"world_width": world_width, "world_height": world_height,
|
||||
}
|
||||
return render_template(
|
||||
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
|
||||
)
|
||||
|
||||
|
||||
def _render_full_dashboard(slug, game):
|
||||
definitions = db.list_definitions(slug)
|
||||
# Détail complet (avec ses champs) de chaque objet, pour le panneau
|
||||
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
|
||||
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
|
||||
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
|
||||
# l'ouverture (voir openObjectEditPanel()).
|
||||
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
|
||||
|
||||
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
|
||||
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
|
||||
# objet, les libellés lisibles de ses champs relation (pour l'affichage
|
||||
# du tableau) et les options possibles de ces mêmes champs relation
|
||||
# (pour le formulaire "Ajouter une entrée").
|
||||
rows_by_definition = {}
|
||||
relation_labels_by_definition = {}
|
||||
relation_options_by_definition = {}
|
||||
referenced_by_definition = {}
|
||||
for d in definitions:
|
||||
full = definitions_full[d["id"]]
|
||||
rows_by_definition[d["id"]] = db.list_rows(slug, full)
|
||||
relation_labels = {}
|
||||
relation_options = {}
|
||||
for f in full["fields"]:
|
||||
if f["type"] == "relation":
|
||||
options = db.relation_options(slug, f["relation_definition_id"])
|
||||
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
|
||||
relation_options[f["id"]] = options
|
||||
relation_labels_by_definition[d["id"]] = relation_labels
|
||||
relation_options_by_definition[d["id"]] = relation_options
|
||||
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
|
||||
|
||||
screen_list = screens.list_screens(slug)
|
||||
element_types = screens.list_element_types(slug)
|
||||
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
|
||||
variables = db.list_global_variables(slug)
|
||||
return render_template(
|
||||
"game_dashboard.html", game=game, definitions=definitions,
|
||||
definitions_full=definitions_full,
|
||||
rows_by_definition=rows_by_definition,
|
||||
relation_labels_by_definition=relation_labels_by_definition,
|
||||
relation_options_by_definition=relation_options_by_definition,
|
||||
referenced_by_definition=referenced_by_definition,
|
||||
screen_list=screen_list, element_types=element_types,
|
||||
element_type_usage=element_type_usage, variables=variables,
|
||||
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import action_add, action_delete, action_run_data
|
||||
@@ -1,35 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/actions/add", methods=["POST"])
|
||||
def action_add(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
trigger_event = screens._trigger_for(element["widget"], element["attributes"])
|
||||
action_type = request.form.get("action_type", "rien")
|
||||
|
||||
def _int_or_none(key):
|
||||
raw = request.form.get(key)
|
||||
try:
|
||||
return int(raw) if raw not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
screens.add_action(
|
||||
slug, element_id, trigger_event, action_type,
|
||||
target_screen_id=_int_or_none("target_screen_id"),
|
||||
target_element_id=_int_or_none("target_element_id"),
|
||||
element_property=request.form.get("element_property") or None,
|
||||
element_value=request.form.get("element_value") or None,
|
||||
target_definition_id=_int_or_none("target_definition_id"),
|
||||
target_row_id=_int_or_none("target_row_id"),
|
||||
target_field=request.form.get("target_field") or None,
|
||||
data_operation=request.form.get("data_operation") or None,
|
||||
data_value=request.form.get("data_value") or None,
|
||||
)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||
@@ -1,15 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/actions/<int:action_id>/delete", methods=["POST"])
|
||||
def action_delete(slug, action_id):
|
||||
action = screens.get_action(slug, action_id)
|
||||
if not action:
|
||||
abort(404)
|
||||
element = screens.get_element(slug, action["element_id"])
|
||||
screens.delete_action(slug, action_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element["id"]))
|
||||
@@ -1,17 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/actions/<int:action_id>/run-data", methods=["POST"])
|
||||
def action_run_data(slug, action_id):
|
||||
"""Exécuté côté serveur au moment du clic, en mode jouable, pour une
|
||||
action "Modifier une donnée d'un objet" — les actions "Modifier un
|
||||
élément" restent, elles, purement côté client (juste du style CSS)."""
|
||||
action = screens.get_action(slug, action_id)
|
||||
if not action or action.get("action_type") != "modifier_donnee":
|
||||
return jsonify({"ok": False}), 400
|
||||
ok = screens.apply_data_action(slug, action)
|
||||
return jsonify({"ok": ok})
|
||||
@@ -1,14 +0,0 @@
|
||||
from . import (
|
||||
parse_field_rows,
|
||||
object_new,
|
||||
object_view,
|
||||
object_edit,
|
||||
object_field_add,
|
||||
object_field_edit,
|
||||
object_field_delete,
|
||||
object_delete,
|
||||
data_form,
|
||||
data_new,
|
||||
data_edit,
|
||||
data_delete,
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/delete", methods=["POST"])
|
||||
def data_delete(slug, definition_id, row_id):
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
referencing = db.rows_referencing(slug, definition_id, row_id)
|
||||
if referencing:
|
||||
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
|
||||
# une relation. On revient au panneau de cet objet (onglet
|
||||
# Données), qui explique pourquoi via un paramètre de requête
|
||||
# simple plutôt qu'une session/flash message.
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
|
||||
db.delete_row(slug, definition, row_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
||||
@@ -1,17 +0,0 @@
|
||||
from flask import abort
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
from .data_form import _data_form
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/edit", methods=["GET", "POST"])
|
||||
def data_edit(slug, definition_id, row_id):
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
row = db.get_row(slug, definition, row_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
return _data_form(slug, definition_id, row=row)
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _data_form(slug, definition_id, row):
|
||||
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
|
||||
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
|
||||
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
|
||||
(data_form.html, retirée) — cette fonction ne fait donc plus que
|
||||
traiter la soumission de ce formulaire. Un GET direct redirige vers le
|
||||
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
|
||||
Données."""
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
|
||||
if request.method == "POST":
|
||||
if row is None:
|
||||
db.insert_row(slug, definition, request.form)
|
||||
else:
|
||||
db.update_row(slug, definition, row["id"], request.form)
|
||||
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
||||
@@ -1,7 +0,0 @@
|
||||
from core.flask_app import app
|
||||
from .data_form import _data_form
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/new", methods=["GET", "POST"])
|
||||
def data_new(slug, definition_id):
|
||||
return _data_form(slug, definition_id, row=None)
|
||||
@@ -1,19 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/delete", methods=["POST"])
|
||||
def object_delete(slug, definition_id):
|
||||
referenced_by = db.definitions_referencing(slug, definition_id)
|
||||
if referenced_by:
|
||||
# Suppression bloquée : d'autres objets ont une relation vers
|
||||
# celui-ci. On revient à son panneau, qui affiche déjà cet
|
||||
# avertissement (referenced_by_definition, voir game_dashboard.py)
|
||||
# en permanence, pas seulement après une tentative — plutôt que de
|
||||
# casser ces relations silencieusement.
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
db.delete_definition(slug, definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
|
||||
def object_edit(slug, definition_id):
|
||||
"""Renommer un objet + gérer ses champs se fait désormais depuis un
|
||||
panneau déplaçable/redimensionnable sur le tableau de bord (voir
|
||||
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
|
||||
qu'une page dédiée — cette route ne fait donc plus que traiter le
|
||||
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
|
||||
le panneau de cet objet (?edit=<id>)."""
|
||||
if request.method == "POST":
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
new_name = request.form.get("object_name", "").strip()
|
||||
if new_name and new_name != definition["name"]:
|
||||
db.rename_definition(slug, definition_id, new_name)
|
||||
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,26 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/add", methods=["POST"])
|
||||
def object_field_add(slug, definition_id):
|
||||
name = request.form.get("field_name", "").strip()
|
||||
field_type = request.form.get("field_type", "texte")
|
||||
relation_definition_id = request.form.get("field_relation")
|
||||
# Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet
|
||||
# cible valide ferait planter add_field_to_definition (int("") lève),
|
||||
# ce qui annulait l'ALTER TABLE avant son commit() -- le champ
|
||||
# n'était alors ajouté du tout, sans le moindre message d'erreur.
|
||||
if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()):
|
||||
db.add_field_to_definition(slug, definition_id, {
|
||||
"name": name,
|
||||
"type": field_type,
|
||||
"relation_definition_id": relation_definition_id,
|
||||
"required": request.form.get("field_required") == "1",
|
||||
"min_value": request.form.get("field_min") or None,
|
||||
"max_value": request.form.get("field_max") or None,
|
||||
})
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,11 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
|
||||
def object_field_delete(slug, definition_id, field_id):
|
||||
db.delete_field(slug, definition_id, field_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,20 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/edit", methods=["POST"])
|
||||
def object_field_edit(slug, definition_id, field_id):
|
||||
new_name = request.form.get("field_name", "").strip()
|
||||
if new_name:
|
||||
db.update_field(
|
||||
slug, definition_id, field_id,
|
||||
new_name=new_name,
|
||||
required=request.form.get("field_required") == "1",
|
||||
relation_definition_id=request.form.get("field_relation"),
|
||||
min_value=request.form.get("field_min") or None,
|
||||
max_value=request.form.get("field_max") or None,
|
||||
)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,35 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
from .parse_field_rows import _parse_field_rows
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/new", methods=["GET", "POST"])
|
||||
def object_new(slug):
|
||||
"""La création d'un objet se fait désormais depuis un panneau déplaçable
|
||||
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
|
||||
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
|
||||
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
|
||||
externe obsolète) redirige simplement vers le tableau de bord."""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("object_name", "").strip()
|
||||
# Aucun champ requis à la création : create_definition(fields=[])
|
||||
# crée simplement une table sans colonne "métier" (juste id/
|
||||
# created_at), et le panneau "Modifier un objet" (onglet Champs,
|
||||
# bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins
|
||||
# un champ ici faisait échouer silencieusement la création (aucun
|
||||
# message d'erreur, juste un retour au tableau de bord) dès qu'on
|
||||
# tapait le nom sans avoir encore cliqué "+ Ajouter un champ".
|
||||
fields = _parse_field_rows(request.form)
|
||||
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
|
||||
# explicitement rend l'objet partagé par tous les joueurs — ex. un
|
||||
# catalogue/une liste de questions — voir db/definitions/
|
||||
# create_definition.py).
|
||||
per_player = request.form.get("per_player", "1") == "1"
|
||||
if name:
|
||||
definition_id = db.create_definition(slug, name, fields, per_player=per_player)
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
@@ -1,18 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>")
|
||||
def object_view(slug, definition_id):
|
||||
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
|
||||
retirée : les données vivent désormais dans l'onglet "Données" du
|
||||
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
|
||||
route reste néanmoins en place, comme pur redirecteur, pour deux
|
||||
raisons : un lien externe/ancien vers cette URL continue de mener
|
||||
quelque part de sensé, et surtout beaucoup de tests (et certains
|
||||
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
|
||||
segment du chemin renvoyé par le header Location d'une redirection
|
||||
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
|
||||
termine par l'id), qui redirige ensuite vers le tableau de bord."""
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,36 +0,0 @@
|
||||
def _parse_field_rows(form):
|
||||
field_names = form.getlist("field_name[]")
|
||||
field_types = form.getlist("field_type[]")
|
||||
field_relations = form.getlist("field_relation[]")
|
||||
field_required = form.getlist("field_required[]")
|
||||
field_min = form.getlist("field_min[]")
|
||||
field_max = form.getlist("field_max[]")
|
||||
fields = []
|
||||
for i, fname in enumerate(field_names):
|
||||
if not fname.strip():
|
||||
continue
|
||||
ftype = field_types[i]
|
||||
relation_definition_id = field_relations[i] if i < len(field_relations) else None
|
||||
if ftype == "relation" and not (relation_definition_id or "").strip().isdigit():
|
||||
# Type "Relation vers un autre objet" choisi sans (ou avant
|
||||
# d'avoir pu) sélectionner d'objet cible réel — ex. le tout
|
||||
# premier objet créé dans un jeu, quand ce sélecteur est encore
|
||||
# vide faute d'un autre objet à pointer. create_definition()
|
||||
# fait un int(relation_definition_id) sans filet : le laisser
|
||||
# passer plantait TOUTE la requête (500), et comme la ligne
|
||||
# _definitions n'était alors jamais validée (pas de commit()
|
||||
# atteint), l'objet n'était pas créé du tout — pas de champ
|
||||
# "Relation" non plus, mais un panneau qui semble "ne rien
|
||||
# faire" plutôt qu'un message d'erreur clair. On ignore donc ce
|
||||
# champ précis (comme une ligne sans nom, ci-dessus) plutôt que
|
||||
# de faire échouer la création de TOUT l'objet à cause de lui.
|
||||
continue
|
||||
fields.append({
|
||||
"name": fname.strip(),
|
||||
"type": ftype,
|
||||
"relation_definition_id": relation_definition_id,
|
||||
"required": field_required[i] == "1" if i < len(field_required) else False,
|
||||
"min_value": (field_min[i].strip() or None) if i < len(field_min) else None,
|
||||
"max_value": (field_max[i].strip() or None) if i < len(field_max) else None,
|
||||
})
|
||||
return fields
|
||||
@@ -9,4 +9,6 @@ from . import (
|
||||
scene_object_collision_rules,
|
||||
scene_object_name,
|
||||
scene_object_dialogue_box_style,
|
||||
scene_collision_rule_objects,
|
||||
scene_object_names_list,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import screens
|
||||
|
||||
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
|
||||
|
||||
|
||||
def build_collision_rule_objects(objects):
|
||||
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
|
||||
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
|
||||
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||
screens/rendering/collision_rules.py) — factorisé ici plutôt que
|
||||
dupliqué : utilisé au premier rendu de la page (routes/scenes/
|
||||
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
|
||||
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
|
||||
scene-editor.js) pour reconstruire la liste SANS recharger la page.
|
||||
|
||||
`objects` doit déjà porter "rendered_html" (voir
|
||||
screens.render_scene_object) sur chaque élément.
|
||||
|
||||
resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans
|
||||
rôle explicitement enregistré (voir screens/rendering/
|
||||
personnage_role.py — comportement voulu pour une scène à un seul
|
||||
personnage, posé avant l'ajout de ce réglage). Appliqué tel quel,
|
||||
ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite
|
||||
(le cas de toute scène créée avant ce réglage — un personnage ajouté
|
||||
depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite,
|
||||
voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et
|
||||
disparaîtraient donc TOUS de cette liste. On ne considère donc comme
|
||||
"joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le
|
||||
plus ancien, z_index croissant comme `objects`) — les suivants sans
|
||||
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
|
||||
base."""
|
||||
implicit_joueur_claimed = any(
|
||||
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
|
||||
for o in objects if o["kind"] == "personnage"
|
||||
)
|
||||
result = []
|
||||
for o in objects:
|
||||
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
|
||||
continue
|
||||
role = None
|
||||
if o["kind"] == "personnage":
|
||||
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
|
||||
if explicit_role in screens.PERSONNAGE_ROLES:
|
||||
role = explicit_role
|
||||
elif not implicit_joueur_claimed:
|
||||
role = "joueur"
|
||||
implicit_joueur_claimed = True
|
||||
else:
|
||||
role = "pnj"
|
||||
if role == "joueur":
|
||||
continue
|
||||
result.append({
|
||||
"id": o["id"],
|
||||
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
|
||||
"kind": o["kind"],
|
||||
"role": role,
|
||||
"thumbnail_html": o["rendered_html"],
|
||||
"rules": screens.resolve_collision_rules(o),
|
||||
})
|
||||
return result
|
||||
@@ -0,0 +1,24 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
from .collision_rule_objects import build_collision_rule_objects
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/collision-rule-objects", methods=["GET"])
|
||||
def scene_collision_rule_objects(slug, screen_id):
|
||||
"""Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS,
|
||||
templates/scene_edit.html) — appelée en AJAX après un ajout/suppression
|
||||
d'objet, ou un changement de rôle/nom (voir static/js/scenes/
|
||||
scene-editor.js) qui peut faire apparaître/disparaître un objet de
|
||||
cette liste (rôle passé à/depuis "joueur", voir
|
||||
build_collision_rule_objects) ou changer son libellé affiché (nom).
|
||||
Recalculée entièrement plutôt que diffée : la liste reste petite (un
|
||||
objet de scène par carte, jamais un "fond"/widget d'UI) et cette même
|
||||
fonction est déjà appelée à chaque rendu complet de la page."""
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
return jsonify(build_collision_rule_objects(objects))
|
||||
@@ -5,6 +5,8 @@ from flask import render_template, request, abort, g
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .collision_rule_objects import build_collision_rule_objects
|
||||
|
||||
|
||||
def render_scene_edit(slug, screen_id, game):
|
||||
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
||||
@@ -22,22 +24,17 @@ def render_scene_edit(slug, screen_id, game):
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
|
||||
# "Monde" (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, voir
|
||||
# add_scene_object.py) rend visible/joignable, dans CET éditeur, tout
|
||||
# ce qui dépasse la scène nominale plutôt que de le rogner (le
|
||||
# "Monde" (voir screens.resolve_scene_world_size, réutilisé aussi par
|
||||
# la miniature de "Mes écrans", routes/games/game_dashboard.py) : un
|
||||
# "fond" posé à sa taille RÉELLE (souvent plus grande que la scène,
|
||||
# voir add_scene_object.py) rend visible/joignable, dans CET éditeur,
|
||||
# tout ce qui dépasse la scène nominale plutôt que de le rogner (le
|
||||
# glisser-déposer le permet déjà depuis le fix précédent — sans
|
||||
# agrandir le canevas ici, cette partie restait invisible/injoignable
|
||||
# à la souris). Le rectangle "🎥 Champ de la caméra" (voir
|
||||
# templates/scene_edit.html) marque ensuite où s'arrête la scène
|
||||
# nominale À L'INTÉRIEUR de ce monde plus grand.
|
||||
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"])
|
||||
world_width, world_height = int(world_width), int(world_height)
|
||||
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
||||
|
||||
selected_id = request.args.get("selected", type=int)
|
||||
personnage_data = None
|
||||
@@ -66,28 +63,11 @@ def render_scene_edit(slug, screen_id, game):
|
||||
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
|
||||
}
|
||||
|
||||
# "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet
|
||||
# ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
|
||||
# TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||
# screens/rendering/collision_rules.py) — construit ici plutôt que
|
||||
# réutiliser `objects` tel quel, pour ne poser resolve_personnage_role
|
||||
# qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet
|
||||
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
|
||||
collision_rule_objects = []
|
||||
for o in objects:
|
||||
if o["kind"] in ("fond", "dialogue_box", "quiz_box", "score_widget"):
|
||||
continue
|
||||
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
|
||||
if role == "joueur":
|
||||
continue
|
||||
collision_rule_objects.append({
|
||||
"id": o["id"],
|
||||
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
|
||||
"kind": o["kind"],
|
||||
"role": role,
|
||||
"thumbnail_html": o["rendered_html"],
|
||||
"rules": screens.resolve_collision_rules(o),
|
||||
})
|
||||
# "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/
|
||||
# collision_rule_objects.py, factorisé pour être réutilisé par
|
||||
# l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/
|
||||
# suppression/changement de rôle ou de nom sans recharger la page).
|
||||
collision_rule_objects = build_collision_rule_objects(objects)
|
||||
|
||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
@@ -10,7 +10,20 @@ from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_back
|
||||
def scene_object_add(slug, screen_id):
|
||||
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
|
||||
element_add.py, mais pour _scene_objects (voir screens/scenes/
|
||||
add_scene_object.py)."""
|
||||
add_scene_object.py).
|
||||
|
||||
Appelée en AJAX (voir addSceneObject(), static/js/scenes/scene-editor.js)
|
||||
plutôt qu'en soumission de formulaire classique — sans ça, poser un
|
||||
objet rechargeait TOUTE la page : perceptible comme un "vidage" de
|
||||
l'éditeur de collision/quêtes le temps du rechargement, et perdait tout
|
||||
état JS en cours (modale ouverte, etc.). Répond en JSON avec de quoi
|
||||
ajouter l'objet au canevas SANS recharger — jamais sélectionné/ouvert
|
||||
dans le panneau de propriétés pour autant (ça, voir
|
||||
onSceneObjectMouseDown, reste un rechargement pour l'instant). La liste
|
||||
"🧩 Collision" n'est PAS incluse ici : addSceneObject() la redemande
|
||||
ensuite à routes/scenes/scene_collision_rule_objects.py (même endpoint
|
||||
que pour un changement de rôle/nom, ou une suppression) plutôt que de
|
||||
dupliquer ici la logique de build_collision_rule_objects()."""
|
||||
kind = request.form.get("kind", "personnage")
|
||||
forge_character = request.form.get("forge_character")
|
||||
background_slug = request.form.get("background_slug")
|
||||
@@ -21,7 +34,22 @@ def scene_object_add(slug, screen_id):
|
||||
new_id = screens.add_scene_object(
|
||||
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
|
||||
)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
obj = screens.get_scene_object(slug, new_id)
|
||||
return jsonify({
|
||||
"id": new_id, "kind": obj["kind"],
|
||||
"x": obj["x"], "y": obj["y"], "width": obj["width"], "height": obj["height"],
|
||||
"rendered_html": screens.render_scene_object(obj),
|
||||
# Relance l'aperçu animé tout de suite (voir personnage-preview.js
|
||||
# ::restartPersonnageIdlePreview) — sans ça, un personnage
|
||||
# fraîchement posé restait figé sur sa première frame jusqu'au
|
||||
# prochain rechargement de page (qui n'arrive plus jamais, voir
|
||||
# addSceneObject() côté JS).
|
||||
"animations": screens.resolve_personnage_animations(obj) if obj["kind"] == "personnage" else None,
|
||||
})
|
||||
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
|
||||
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
||||
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
|
||||
# Conservé pour tout appelant non-JS (aucun connu actuellement, mais
|
||||
# ne coûte rien à garder en secours).
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import redirect, url_for
|
||||
from flask import redirect, request, url_for, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
@@ -7,9 +7,24 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
|
||||
def scene_object_delete(slug, object_id):
|
||||
"""Supprime un objet de scène — bouton 🗑️ du panneau de propriétés
|
||||
(voir templates/scene_edit.html), toujours pour l'objet SÉLECTIONNÉ (le
|
||||
seul dont ce bouton est affiché).
|
||||
|
||||
Appelée en AJAX (deleteSceneObject(), static/js/scenes/scene-editor.js)
|
||||
plutôt qu'en soumission de formulaire classique — sinon même
|
||||
"rechargé en douceur" par pjax.js (qui intercepte tout <form>, voir son
|
||||
commentaire d'en-tête), ça remplaçait TOUT <main> (canevas + onglets
|
||||
Collision/Quêtes/Événements), perceptible comme un "vidage" de
|
||||
l'éditeur le temps du remplacement."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if not obj:
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": False}), 404
|
||||
return redirect(url_for("index"))
|
||||
screen_id = obj["scene_id"]
|
||||
screens.delete_scene_object(slug, object_id)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": True, "id": object_id})
|
||||
# Secours pour tout appelant non-JS (aucun connu actuellement).
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scene-object-names", methods=["GET"])
|
||||
def scene_object_names_list(slug):
|
||||
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
|
||||
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
|
||||
templates/scene_edit.html, questSpeakerOptions() dans static/js/
|
||||
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
|
||||
chargement complet de la page (HORS de #builder3, jamais rafraîchie
|
||||
par une sélection d'objet sans rechargement, voir goToSelected()) :
|
||||
sans cet endpoint, renommer un personnage puis rouvrir la modale de
|
||||
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
|
||||
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
|
||||
temps — appelée en AJAX depuis saveSceneObjectName()."""
|
||||
return jsonify(screens.list_named_scene_object_names(slug))
|
||||
@@ -1 +1 @@
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music, screen_set_scene_size
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_background_music, screen_set_scene_size
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import json
|
||||
|
||||
from flask import render_template, request, abort, g
|
||||
from flask import abort
|
||||
|
||||
import db
|
||||
import screens
|
||||
@@ -15,124 +13,6 @@ def screen_edit(slug, screen_id):
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if not screen:
|
||||
abort(404)
|
||||
if screen["kind"] == "jeu_2d":
|
||||
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
|
||||
# le type d'écran ("document" ou "jeu_2d") est une propriété de
|
||||
# L'ÉCRAN, pas du projet — même route/endpoint pour les deux,
|
||||
# dispatch ici selon l'écran demandé — tout le reste de cette
|
||||
# fonction ne concerne QUE l'éditeur document (comportement
|
||||
# inchangé pour un écran "document").
|
||||
return render_scene_edit(slug, screen_id, game)
|
||||
elements = screens.list_elements(slug, screen_id)
|
||||
all_screens = screens.list_screens(slug)
|
||||
element_types = screens.list_element_types(slug)
|
||||
selected_id = request.args.get("selected", type=int)
|
||||
selected_controls = None
|
||||
personnage_data = None
|
||||
if selected_id:
|
||||
selected_el = next((e for e in elements if e["id"] == selected_id), None)
|
||||
if selected_el:
|
||||
template_definition_id = None
|
||||
if screen.get("is_template"):
|
||||
owning_type = screens.get_element_type_by_template_screen(slug, screen_id)
|
||||
if owning_type:
|
||||
template_definition_id = owning_type.get("bound_definition_id")
|
||||
selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id)
|
||||
if selected_el.get("widget") == "personnage":
|
||||
# Phase 8 — panneau de propriétés sur-mesure (custom_panel,
|
||||
# voir registry.py) : pas de controls génériques à afficher,
|
||||
# juste la source (Forge/personnalisée) et son contenu.
|
||||
personnage_data = screens.resolve_personnage_data(selected_el)
|
||||
children_of = {}
|
||||
for el in elements:
|
||||
if el.get("parent_id"):
|
||||
children_of.setdefault(el["parent_id"], []).append(el)
|
||||
top_level_elements = [el for el in elements if not el.get("parent_id")]
|
||||
elements_by_id = {el["id"]: el for el in elements}
|
||||
|
||||
definitions = db.list_definitions(slug)
|
||||
definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
|
||||
# Phase 4 — nodeLabel() (static/js/screen_edit/flow-editor.js) affiche
|
||||
# le nom de l'objet visé par un nœud "Ajouter une ligne" ; DEFINITIONS_DATA
|
||||
# (ci-dessus) ne porte que lignes/champs, jamais le nom lui-même.
|
||||
definitions_names = {d["id"]: d["name"] for d in definitions}
|
||||
|
||||
# list_flow_blocks() rattache paresseusement les nœuds "orphelins"
|
||||
# (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" —
|
||||
# DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes
|
||||
# garderait en mémoire un block_id périmé (None) pour ces nœuds.
|
||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||
flow_edges = screens.list_flow_edges(slug, screen_id)
|
||||
elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements}
|
||||
screens_map = {s["id"]: s["name"] for s in all_screens}
|
||||
# Phase 8 — sélecteur d'animation à miniatures (nœud de flow "Jouer une
|
||||
# animation" / clip de Timeline "sprite") : scopé aux SEULES animations
|
||||
# de l'élément personnage réellement ciblé, jamais un catalogue global.
|
||||
element_animations_map = {
|
||||
el["id"]: screens.resolve_personnage_animations(el) for el in elements if el.get("widget") == "personnage"
|
||||
}
|
||||
animation_clips = screens.list_animation_clips(slug, screen_id)
|
||||
global_variables = db.list_global_variables(slug)
|
||||
custom_events = db.list_custom_events(slug)
|
||||
custom_event_usages = screens.list_custom_event_usages(slug)
|
||||
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
|
||||
|
||||
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels)
|
||||
|
||||
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
|
||||
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
|
||||
# "Bibliothèque de sprites animaux CraftPix") — un compte "user" ne
|
||||
# doit même pas les voir dans la galerie.
|
||||
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
|
||||
sprite_library_families = screens.sprite_gallery_families(sprite_library)
|
||||
|
||||
return render_template(
|
||||
"screen_edit.html",
|
||||
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
|
||||
children_of=children_of, container_widgets=screens.CONTAINER_WIDGETS,
|
||||
elements_by_id=elements_by_id,
|
||||
all_screens=all_screens,
|
||||
element_types=element_types, widgets=screens.WIDGETS,
|
||||
trigger_labels=screens.TRIGGER_LABELS, action_type_labels=screens.ACTION_TYPE_LABELS,
|
||||
element_action_properties=screens.ELEMENT_ACTION_PROPERTIES,
|
||||
element_action_property_labels=screens.ELEMENT_ACTION_PROPERTY_LABELS,
|
||||
element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES,
|
||||
element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS,
|
||||
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
|
||||
element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES,
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
personnage_data=personnage_data,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
data_operations=screens.DATA_OPERATIONS,
|
||||
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
||||
score_status_choices=db.SCORE_STATUS_CHOICES,
|
||||
score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS),
|
||||
definitions=definitions, definitions_json=json.dumps(definitions_data),
|
||||
selected_id=selected_id, selected_controls=selected_controls,
|
||||
flow_nodes=flow_nodes, flow_edges=flow_edges,
|
||||
flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges),
|
||||
trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS,
|
||||
elements_labels_json=json.dumps(elements_labels), screens_map_json=json.dumps(screens_map),
|
||||
action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS),
|
||||
element_action_property_labels_json=json.dumps(screens.ELEMENT_ACTION_PROPERTY_LABELS),
|
||||
element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS),
|
||||
element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS),
|
||||
element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_LABELS),
|
||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
|
||||
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
||||
trigger_labels_json=json.dumps(screens.TRIGGER_LABELS),
|
||||
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
|
||||
animate_css_catalog=screens.ANIMATE_CSS_CATALOG,
|
||||
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
|
||||
fontawesome_icons=screens.FONTAWESOME_ICONS,
|
||||
global_variables=global_variables,
|
||||
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
||||
custom_events_map_json=json.dumps(custom_events_map),
|
||||
blocks_view=blocks_view,
|
||||
flow_blocks_json=json.dumps(flow_blocks),
|
||||
definitions_names_json=json.dumps(definitions_names),
|
||||
)
|
||||
# Un seul moteur d'écran désormais (jeu_2d) — voir
|
||||
# screens/screens_repo/ensure_schema.py.
|
||||
return render_scene_edit(slug, screen_id, game)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/aspect", methods=["POST"])
|
||||
def screen_set_aspect(slug, screen_id):
|
||||
"""Mémorise le format d'aperçu (Portrait/Paysage/Carré) choisi pour cet
|
||||
écran, en AJAX — sans recharger la page, pour ne jamais perdre ce choix
|
||||
au prochain ajout/suppression d'élément ou changement de sélection (qui
|
||||
redemandent le panneau au serveur)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
aspect = body.get("aspect", "")
|
||||
screens.set_screen_aspect(slug, screen_id, aspect)
|
||||
return jsonify({"ok": True})
|
||||
@@ -9,18 +9,9 @@ from core.flask_app import app
|
||||
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
||||
def screens_new(slug):
|
||||
name = request.form.get("name", "").strip()
|
||||
if db.is_restricted(slug):
|
||||
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
|
||||
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
|
||||
# imposé par le projet (jeu_2d pour un RPG, document pour les
|
||||
# autres), même valeur pour TOUS ses écrans (voir
|
||||
# db/games/game_type_catalog.py).
|
||||
onboarding_type = db.get_onboarding_type(slug)
|
||||
kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
|
||||
else:
|
||||
kind = request.form.get("kind", "document")
|
||||
if kind not in ("document", "jeu_2d"):
|
||||
kind = "document"
|
||||
# Un seul moteur d'écran désormais (jeu_2d) — imposé à tout écran créé,
|
||||
# quel que soit le projet (voir db/games/game_type_catalog.py).
|
||||
kind = "jeu_2d"
|
||||
if name:
|
||||
screen_id = screens.create_screen(slug, name, kind=kind)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
|
||||
+10
-67
@@ -1,34 +1,15 @@
|
||||
"""
|
||||
screens.py — écrans de jeu : éléments posés en glisser-déposer sur un
|
||||
écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans,
|
||||
et actions déclenchées par clic ou soumission de formulaire.
|
||||
|
||||
Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de
|
||||
WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de
|
||||
données...), chacun avec ses propres réglages ("controls") : un réglage =
|
||||
un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom
|
||||
de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget
|
||||
correspond à une vraie balise HTML avec de vrais attributs et un vrai
|
||||
style CSS ; l'utilisateur ne les manipule jamais directement.
|
||||
screens.py — écrans de jeu : un seul moteur d'écran, la scène 2D "jeu_2d"
|
||||
(voir screens/scenes/) — objets posés en pixels, enchaînement des écrans,
|
||||
et logique déclenchée par clic/collision/clavier/minuteur (voir
|
||||
screens/flow/).
|
||||
|
||||
Tout est stocké dans la même base SQLite que le reste du jeu
|
||||
(projects/<slug>/game.db) :
|
||||
- _screens : un écran du jeu (accueil, formulaire, scène...)
|
||||
- _element_types : le "catalogue" des éléments de jeu réutilisables
|
||||
(Personnage, Outil, IA...) que l'utilisateur crée
|
||||
- _screen_elements : chaque élément posé sur un écran (widget, position
|
||||
et taille en POURCENTAGE de l'écran, contenu,
|
||||
attributs et style — dérivés des réglages du
|
||||
widget, jamais saisis en texte libre)
|
||||
- _actions : ce qui se passe au clic / à la soumission d'un
|
||||
élément (écran suivant, écran précédent, aller à
|
||||
un écran précis, ne rien faire)
|
||||
|
||||
Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu
|
||||
fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche
|
||||
automatiquement une ligne par donnée existante — par exemple, tous les
|
||||
niveaux d'un parcours — à partir d'un modèle de texte avec des
|
||||
{{nom_de_champ}}.
|
||||
- _screens : un écran du jeu (une scène...)
|
||||
- _scene_objects : chaque objet posé sur une scène (personnage,
|
||||
décor, fond, widgets d'interface...)
|
||||
- _flow_nodes/_flow_edges : le graphe de logique de chaque écran
|
||||
|
||||
Ce fichier est désormais un package (voir les sous-dossiers) organisé
|
||||
selon "un fichier = une fonction, un dossier = une responsabilité" ; ce
|
||||
@@ -37,20 +18,10 @@ utilisée par app.py et les templates, afin que `import screens` continue
|
||||
de fonctionner à l'identique.
|
||||
"""
|
||||
|
||||
from .widgets.registry import WIDGETS
|
||||
from .widgets.container_widgets import CONTAINER_WIDGETS
|
||||
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
|
||||
from .widgets.widget_meta import widget_meta
|
||||
from .widgets.controls_with_values import controls_with_values
|
||||
from .widgets.data_definition_options import data_definition_options
|
||||
from .data_definitions.data_definition_options import data_definition_options
|
||||
|
||||
from .labels.trigger_labels import TRIGGER_LABELS
|
||||
from .labels.action_type_labels import ACTION_TYPE_LABELS
|
||||
from .labels.element_action_properties import (
|
||||
ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
|
||||
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
|
||||
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS,
|
||||
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
|
||||
)
|
||||
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
|
||||
@@ -78,7 +49,6 @@ from .flow.constants import (
|
||||
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
|
||||
)
|
||||
|
||||
from .rendering.render_element_html import render_element_html
|
||||
from .rendering.trigger_for import _trigger_for
|
||||
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
|
||||
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
|
||||
@@ -99,27 +69,13 @@ from .screens_repo.rename_screen import rename_screen
|
||||
from .screens_repo.delete_screen import delete_screen
|
||||
from .screens_repo.move_screen import move_screen
|
||||
from .screens_repo.screens_referencing import screens_referencing
|
||||
from .screens_repo.set_screen_aspect import set_screen_aspect
|
||||
from .screens_repo.set_scene_size import set_scene_size
|
||||
from .screens_repo.set_screen_background_music import set_screen_background_music
|
||||
|
||||
from .elements.list_elements import list_elements
|
||||
from .elements.get_element import get_element
|
||||
from .elements.add_element import add_element
|
||||
from .elements.add_tab import add_tab
|
||||
from .elements.update_element_geometry import update_element_geometry
|
||||
from .elements.reorder_element import reorder_element
|
||||
from .elements.move_element_to_container import move_element_to_container
|
||||
from .elements.save_element_controls import save_element_controls
|
||||
from .elements.delete_element import delete_element
|
||||
from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element
|
||||
from .elements.duplicate_element import duplicate_element
|
||||
from .elements.rename_element import rename_element
|
||||
from .elements.set_personnage_data import set_personnage_data
|
||||
|
||||
from .scenes.ensure_scene_schema import ensure_scene_schema
|
||||
from .scenes.add_scene_object import add_scene_object
|
||||
from .scenes.list_scene_objects import list_scene_objects
|
||||
from .scenes.resolve_scene_world_size import resolve_scene_world_size
|
||||
from .scenes.get_scene_object import get_scene_object
|
||||
from .scenes.update_scene_object_geometry import update_scene_object_geometry
|
||||
from .scenes.delete_scene_object import delete_scene_object
|
||||
@@ -132,19 +88,6 @@ from .scenes.set_scene_object_name import set_scene_object_name
|
||||
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||
from .scenes.render_scene_object import render_scene_object
|
||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||
from .elements.element_label import element_label
|
||||
|
||||
from .element_types.list_element_types import list_element_types
|
||||
from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen
|
||||
from .element_types.create_element_type import create_element_type
|
||||
from .element_types.update_element_type import update_element_type
|
||||
from .element_types.element_type_usage_count import element_type_usage_count
|
||||
from .element_types.delete_element_type import delete_element_type
|
||||
|
||||
from .legacy_actions.list_actions import list_actions
|
||||
from .legacy_actions.add_action import add_action
|
||||
from .legacy_actions.get_action import get_action
|
||||
from .legacy_actions.delete_action import delete_action
|
||||
|
||||
from .flow.ensure_flow_schema import ensure_flow_schema
|
||||
from .flow.list_flow_nodes import list_flow_nodes
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...)."""
|
||||
@@ -1,21 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..screens_repo.ensure_schema import ensure_schema
|
||||
from ..screens_repo.create_screen import create_screen
|
||||
|
||||
|
||||
def create_element_type(slug, name, icon, bound_definition_id=None):
|
||||
"""Un élément de jeu est un conteneur réutilisable : sa "recette" (les
|
||||
éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle
|
||||
caché, créé ici en même temps que l'entrée de catalogue — on le
|
||||
remplit ensuite exactement comme le contenu de n'importe quel
|
||||
conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle."""
|
||||
ensure_schema(slug)
|
||||
template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)",
|
||||
(name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,15 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def delete_element_type(slug, element_type_id):
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute(
|
||||
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
|
||||
).fetchone()
|
||||
conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,))
|
||||
if row and row["template_screen_id"]:
|
||||
# Supprime aussi l'écran-modèle caché associé — son propre contenu
|
||||
# (_screen_elements) part avec lui via ON DELETE CASCADE.
|
||||
conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,10 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def element_type_usage_count(slug, element_type_id):
|
||||
conn = db.connect(slug)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,)
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
return count
|
||||
@@ -1,16 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..screens_repo.ensure_schema import ensure_schema
|
||||
|
||||
|
||||
def get_element_type_by_template_screen(slug, screen_id):
|
||||
"""L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST
|
||||
cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur
|
||||
d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu
|
||||
(bound_definition_id, réglé à la création — voir element_types.html)
|
||||
sans devoir tout lister soi-même."""
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -1,16 +0,0 @@
|
||||
from .load_template_tree import _load_template_tree
|
||||
|
||||
|
||||
def _is_overlay_only_element_type(slug, element_type_id):
|
||||
"""True si le modèle d'un élément de jeu N'EST QU'une "Superposition /
|
||||
boîte de dialogue" (un seul élément de premier niveau, de ce widget) —
|
||||
utilisé à deux endroits qui doivent s'accorder : render_element_html.py
|
||||
(court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui
|
||||
resterait sinon visible en permanence) et element_style_filter.py (le
|
||||
cadre .canvasElement/.playElement de cet exemplaire ne doit pas non
|
||||
plus poser de z-index, pour la même raison qu'une superposition posée
|
||||
directement — voir son propre commentaire)."""
|
||||
template_tree = _load_template_tree(slug, element_type_id)
|
||||
if not template_tree:
|
||||
return False
|
||||
return {c.get("widget") for c in template_tree["top"]} == {"superposition"}
|
||||
@@ -1,19 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..screens_repo.ensure_schema import ensure_schema
|
||||
|
||||
|
||||
def list_element_types(slug):
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["default_style"] = json.loads(d["default_style"] or "{}")
|
||||
d["default_attributes"] = json.loads(d["default_attributes"] or "{}")
|
||||
result.append(d)
|
||||
return result
|
||||
@@ -1,49 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _load_template_tree(slug, element_type_id):
|
||||
"""Charge le contenu (éléments de premier niveau + descendants) de
|
||||
l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier
|
||||
quoi que ce soit en base) — utilisé quand un Répéteur de données affiche
|
||||
cet élément de jeu comme modèle de ligne (voir _render_repeater) : on
|
||||
relit et on rejoue son arbre à chaque ligne de données, avec les
|
||||
{{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou
|
||||
plus) d'écran-modèle valide."""
|
||||
conn = db.connect(slug)
|
||||
et_row = conn.execute(
|
||||
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
|
||||
).fetchone()
|
||||
if not et_row or not et_row["template_screen_id"]:
|
||||
conn.close()
|
||||
return None
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
|
||||
(et_row["template_screen_id"],),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
elements = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["style"] = json.loads(d["style"] or "{}")
|
||||
d["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
elements.append(d)
|
||||
children_map = {}
|
||||
for d in elements:
|
||||
if d.get("parent_id"):
|
||||
children_map.setdefault(d["parent_id"], []).append(d)
|
||||
top = [d for d in elements if not d.get("parent_id")]
|
||||
if not top:
|
||||
return None
|
||||
# Astuce de rendu : ces éléments sont "de premier niveau" dans leur
|
||||
# écran-modèle (parent_id NULL en base, donc positionnés en absolu par
|
||||
# défaut), mais une fois le modèle réutilisé ailleurs (ligne de
|
||||
# Répéteur), ils doivent se comporter comme des enfants en flux normal,
|
||||
# pas comme des boîtes positionnées en absolu sur un écran plein. On leur
|
||||
# donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien
|
||||
# que pour ce rendu éphémère — _style_string ne teste que sa présence,
|
||||
# jamais sa valeur exacte, et cette copie n'est jamais enregistrée.
|
||||
for t in top:
|
||||
t["parent_id"] = t.get("parent_id") or "template-root"
|
||||
return {"top": top, "children_map": children_map}
|
||||
@@ -1,13 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def update_element_type(slug, element_type_id, name=None, bound_definition_id=None):
|
||||
conn = db.connect(slug)
|
||||
if name:
|
||||
conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id))
|
||||
conn.execute(
|
||||
"UPDATE _element_types SET bound_definition_id = ? WHERE id = ?",
|
||||
(int(bound_definition_id) if bound_definition_id else None, element_type_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
"""Éléments posés sur un écran (_screen_elements)."""
|
||||
@@ -1,87 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..widgets.registry import WIDGETS
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.default_style_for_widget import default_style_and_attributes
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
|
||||
"""parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur
|
||||
de cet élément parent (qui doit être un conteneur/répéteur/groupe de
|
||||
champs) plutôt que directement sur l'écran — sa position/taille ne sert
|
||||
alors plus (il suit le flux du parent), mais on garde des valeurs par
|
||||
défaut pour rester cohérent si jamais il en sort un jour.
|
||||
|
||||
element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
|
||||
comme n'importe quel widget), qui garde juste une référence vers cet
|
||||
élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
|
||||
copié en base : il est rechargé EN DIRECT à chaque affichage (voir
|
||||
render_element_html.py), exactement comme un modèle de ligne de
|
||||
Répéteur (voir _load_template_tree/_render_repeater). Modifier
|
||||
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
||||
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
||||
retoucher un par un — au prix de ne plus pouvoir personnaliser
|
||||
individuellement le CONTENU d'un exemplaire précis (sa position/taille
|
||||
sur l'écran, elles, restent propres à chaque exemplaire, comme
|
||||
d'habitude)."""
|
||||
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
|
||||
if element_type_id:
|
||||
types = {t["id"]: t for t in list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
if et:
|
||||
widget_key = et.get("default_widget") or widget_key
|
||||
meta = widget_meta(widget_key)
|
||||
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
|
||||
# création (ex: une image démarre avec "cover" pour se recadrer
|
||||
# proprement) — hors couleurs, qui restent volontairement transparentes/
|
||||
# sans bordure tant qu'on ne les choisit pas explicitement.
|
||||
attributes, style = default_style_and_attributes(widget_key)
|
||||
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
|
||||
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
|
||||
# de dialogue" (render_element_html.py décide alors, au rendu, de
|
||||
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
|
||||
# démarre masqué par défaut, exactement comme une superposition
|
||||
# posée directement (voir default_style_for_widget.py) — sinon un
|
||||
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
|
||||
# sa pose, avant même qu'une action ne l'affiche.
|
||||
style["display"] = "none"
|
||||
content = meta.get("default_content", "")
|
||||
width, height = meta.get("w", 30), meta.get("h", 10)
|
||||
|
||||
conn = db.connect(slug)
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,)
|
||||
).fetchone()["m"]
|
||||
# Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT
|
||||
# SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le
|
||||
# précédent et le recouvre entièrement — ce qui peut donner l'impression
|
||||
# trompeuse qu'un "élément indésirable" vient d'apparaître et d'en
|
||||
# cacher un autre (le plus souvent après une confusion entre "Ajouter un
|
||||
# élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous
|
||||
# les deux un nouvel élément mais pas au même endroit dans l'arbre).
|
||||
x = y = 10
|
||||
if not parent_id:
|
||||
top_level_count = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL",
|
||||
(screen_id,),
|
||||
).fetchone()["c"]
|
||||
offset = (top_level_count % 8) * 4
|
||||
x = y = 10 + offset
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements
|
||||
(screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes),
|
||||
x, y, width, height, (max_z or 0) + 1,
|
||||
json.dumps(style),
|
||||
),
|
||||
)
|
||||
element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return element_id
|
||||
@@ -1,19 +0,0 @@
|
||||
import db
|
||||
|
||||
from .add_element import add_element
|
||||
from .rename_element import rename_element
|
||||
|
||||
|
||||
def add_tab(slug, onglets_id, screen_id):
|
||||
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
|
||||
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
|
||||
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
|
||||
modifiable ensuite comme celui de n'importe quel élément."""
|
||||
conn = db.connect(slug)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
|
||||
rename_element(slug, tab_id, f"Onglet {count + 1}")
|
||||
return tab_id
|
||||
@@ -1,50 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..animations.ensure_animation_schema import ensure_animation_schema
|
||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||
from .element_descendant_ids import element_descendant_ids
|
||||
|
||||
|
||||
def delete_element(slug, element_id):
|
||||
ensure_flow_schema(slug)
|
||||
ensure_animation_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return
|
||||
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
|
||||
# élément"/action "Modifier cet élément"/condition de collision — voir
|
||||
# ci-dessous...) ou une ancienne action du système _actions (conservé
|
||||
# pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES
|
||||
# DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces
|
||||
# colonnes (trigger_element_id/target_element_id) n'ont volontairement
|
||||
# PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être
|
||||
# supprimé "par erreur" en cascade depuis un nœud de logique qu'on
|
||||
# modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir
|
||||
# db/connection.py) fait échouer la suppression elle-même avec
|
||||
# "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour
|
||||
# prévenir l'utilisateur AVANT qu'il confirme, plutôt que de supprimer
|
||||
# ces nœuds en silence.
|
||||
#
|
||||
# cond_element_a/cond_element_b (Phase 5, condition de collision) : ni
|
||||
# contrainte FK ni ON DELETE CASCADE (voir ensure_flow_schema.py), mais
|
||||
# un nœud qui compare le rectangle de CET élément à un autre n'a pas
|
||||
# plus de sens que si c'était trigger_element_id/target_element_id —
|
||||
# supprimé ici pour la même raison, pas parce que la base l'exigerait.
|
||||
conn.execute(
|
||||
f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
|
||||
OR target_element_id IN ({placeholders})
|
||||
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
|
||||
ids + ids + ids + ids,
|
||||
)
|
||||
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
|
||||
# _animation_clips.element_id n'a plus de contrainte FK (voir
|
||||
# ensure_animation_schema.py — réutilisé aussi par les objets de scène
|
||||
# jeu_2d) : nettoyage manuel, même raison que ci-dessus.
|
||||
conn.execute(f"DELETE FROM _animation_clips WHERE element_id IN ({placeholders})", ids)
|
||||
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,60 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def duplicate_element(slug, element_id):
|
||||
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
|
||||
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
|
||||
de l'arborescence n'a pas à faire choisir où poser la copie, elle
|
||||
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
|
||||
sélectionner ensuite), ou None si l'élément n'existe pas."""
|
||||
conn = db.connect(slug)
|
||||
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not root_row:
|
||||
conn.close()
|
||||
return None
|
||||
root = dict(root_row)
|
||||
source = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
|
||||
).fetchall()
|
||||
]
|
||||
conn.close()
|
||||
|
||||
def copy_one(src, new_parent_id, is_root):
|
||||
conn = db.connect(slug)
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
|
||||
).fetchone()["m"]
|
||||
x, y = src["x"], src["y"]
|
||||
if is_root and not new_parent_id:
|
||||
# Petit décalage pour que la copie ne recouvre pas exactement
|
||||
# l'original (qui serait alors invisible en dessous).
|
||||
x = min(100, (x or 0) + 4)
|
||||
y = min(100, (y or 0) + 4)
|
||||
name = src.get("name")
|
||||
if is_root and name:
|
||||
name = f"{name} (copie)"
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements
|
||||
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
|
||||
x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
|
||||
src["content"], src["attributes"], x, y, src["width"], src["height"],
|
||||
(max_z or 0) + 1, src["style"],
|
||||
),
|
||||
)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
def walk(src_parent_id, new_parent_id):
|
||||
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
|
||||
new_child_id = copy_one(child, new_parent_id, is_root=False)
|
||||
walk(child["id"], new_child_id)
|
||||
|
||||
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
|
||||
walk(root["id"], new_root_id)
|
||||
return new_root_id
|
||||
@@ -1,23 +0,0 @@
|
||||
def element_descendant_ids(conn, screen_id, root_id):
|
||||
"""Id de cet élément et de TOUS ses descendants — ceux qu'une
|
||||
suppression en cascade (parent_id ON DELETE CASCADE, voir
|
||||
ensure_schema.py) supprime automatiquement en même temps que lui.
|
||||
Partagé entre delete_element.py (nettoyage des références de la
|
||||
Logique de la scène avant suppression) et flow_nodes_referencing_
|
||||
element.py (pour prévenir l'utilisateur AVANT qu'il confirme la
|
||||
suppression, voir routes/elements/element_delete_impact.py)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
|
||||
).fetchall()
|
||||
children_of = {}
|
||||
for r in rows:
|
||||
if r["parent_id"]:
|
||||
children_of.setdefault(r["parent_id"], []).append(r["id"])
|
||||
ids = [root_id]
|
||||
stack = [root_id]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for child_id in children_of.get(current, []):
|
||||
ids.append(child_id)
|
||||
stack.append(child_id)
|
||||
return ids
|
||||
@@ -1,10 +0,0 @@
|
||||
from ..widgets.registry import WIDGETS
|
||||
|
||||
|
||||
def element_label(el, widgets_catalog=None):
|
||||
"""Nom lisible d'un élément pour les listes/menus : son nom personnalisé
|
||||
s'il en a un, sinon le libellé de son widget (ex: "Image")."""
|
||||
catalog = widgets_catalog if widgets_catalog is not None else WIDGETS
|
||||
if el.get("name"):
|
||||
return el["name"]
|
||||
return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?"))
|
||||
@@ -1,30 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||
from .element_descendant_ids import element_descendant_ids
|
||||
|
||||
|
||||
def flow_nodes_referencing_element(slug, element_id):
|
||||
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
|
||||
élément", action "Modifier cet élément"/"Activer cet onglet", condition
|
||||
de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES
|
||||
DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément
|
||||
(voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT
|
||||
qu'il confirme la suppression (voir la modale de confirmation dans
|
||||
screen_edit.html), plutôt que de les supprimer en silence."""
|
||||
ensure_flow_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return 0
|
||||
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
count = conn.execute(
|
||||
f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
|
||||
OR target_element_id IN ({placeholders})
|
||||
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
|
||||
ids + ids + ids + ids,
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
return count
|
||||
@@ -1,15 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def get_element(slug, element_id):
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["style"] = json.loads(d["style"] or "{}")
|
||||
d["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
return d
|
||||
@@ -1,95 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..rendering.render_element_html import render_element_html
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def list_elements(slug, screen_id, enforce_visibility=False, player_id=db.PLAYER_SHARED):
|
||||
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
|
||||
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
|
||||
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
|
||||
celui d'un conteneur/répéteur/groupe de champs inclut, en le générant
|
||||
récursivement, le HTML de tout ce qui est posé à l'intérieur — un
|
||||
élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le
|
||||
rendered_html de son ancêtre de premier niveau, utilisé pour le rendu
|
||||
HTML initial de la page ; une fois dans le sien propre) : c'est ce
|
||||
dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/
|
||||
refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué
|
||||
(ex. un panneau de détail posé dans un conteneur) sans reconstruire tout
|
||||
son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce
|
||||
changement, seuls les éléments de premier niveau recevaient un
|
||||
rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui
|
||||
retombait donc toujours sur la régénération de son ancêtre de premier
|
||||
niveau, quelle que soit la finesse du ciblage côté JS.
|
||||
|
||||
enforce_visibility : True uniquement pour le mode jouable (voir
|
||||
full_game_payload.py) — applique la "condition de visibilité" de chaque
|
||||
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
|
||||
qui doit continuer à montrer/sélectionner un élément même si sa
|
||||
condition serait actuellement fausse.
|
||||
|
||||
player_id (état par joueur, Phase 1) : posé dans ctx["_forge_player_id"]
|
||||
pour tout le rendu en aval (Répéteur/Jauge/Donnée liée/condition de
|
||||
visibilité) — quel joueur consulte cet écran, pour qu'un objet/une
|
||||
variable per_player affiche SA valeur (voir db/global_vars/,
|
||||
db/rows/)."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["style"] = json.loads(d["style"] or "{}")
|
||||
d["attributes"] = json.loads(d["attributes"] or "{}")
|
||||
result.append(d)
|
||||
|
||||
# Descendant d'un exemplaire d'élément de jeu (element_type_id réglé sur
|
||||
# un ancêtre) : un ancien enfant copié en base par l'ex-instantiate_
|
||||
# template_tree (mécanisme retiré, voir add_element.py) — son contenu
|
||||
# vient désormais TOUJOURS en direct de l'écran-modèle au moment du
|
||||
# rendu (voir render_element_html.py/_render_element_type_children),
|
||||
# jamais de ces lignes, qui peuvent encore traîner en base pour un
|
||||
# exemplaire posé avant ce changement. Exclu ici pour ne plus jamais
|
||||
# apparaître sélectionnable dans l'arborescence de l'éditeur — sinon
|
||||
# ses réglages, plus jamais synchronisés avec le modèle, donnaient
|
||||
# l'impression trompeuse que "les propriétés ne sont pas prises en
|
||||
# compte" (une taille de texte modifiée dans le modèle ne se reflétant
|
||||
# jamais sur cette ligne fantôme, toujours sélectionnable à l'écran).
|
||||
by_id = {d["id"]: d for d in result}
|
||||
|
||||
def _has_linked_ancestor(d):
|
||||
seen = set()
|
||||
parent_id = d.get("parent_id")
|
||||
while parent_id and parent_id not in seen:
|
||||
seen.add(parent_id)
|
||||
parent = by_id.get(parent_id)
|
||||
if not parent:
|
||||
return False
|
||||
if parent.get("element_type_id"):
|
||||
return True
|
||||
parent_id = parent.get("parent_id")
|
||||
return False
|
||||
|
||||
result = [d for d in result if not _has_linked_ancestor(d)]
|
||||
|
||||
children_map = {}
|
||||
for d in result:
|
||||
if d.get("parent_id"):
|
||||
children_map.setdefault(d["parent_id"], []).append(d)
|
||||
play_ctx = {"_forge_play_mode": True, "_forge_player_id": player_id} if enforce_visibility else None
|
||||
for d in result:
|
||||
# Un exemplaire de premier niveau d'un élément de jeu dont le
|
||||
# modèle N'EST QU'une superposition : son cadre de positionnement
|
||||
# (.canvasElement/.playElement, voir filters/element_style_filter.py)
|
||||
# ne doit pas non plus poser de z-index, pour la même raison qu'une
|
||||
# superposition posée directement (sinon elle crée un contexte
|
||||
# d'empilement CSS qui piège le z-index:9999 de la superposition
|
||||
# rendue à l'intérieur — voir element_style_filter.py).
|
||||
if d.get("element_type_id") and not d.get("parent_id"):
|
||||
d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"])
|
||||
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
|
||||
return result
|
||||
@@ -1,62 +0,0 @@
|
||||
import db
|
||||
|
||||
from ..widgets.container_widgets import CONTAINER_WIDGETS
|
||||
|
||||
|
||||
def move_element_to_container(slug, element_id, new_parent_id):
|
||||
"""Déplace element_id pour qu'il devienne un enfant de new_parent_id
|
||||
(glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en
|
||||
DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme
|
||||
d'habitude par glisser-déposer entre frères et sœurs (voir
|
||||
reorder_element.py). Refuse silencieusement (ne touche à rien) si :
|
||||
new_parent_id n'est pas un widget conteneur, si c'est element_id
|
||||
lui-même, ou si c'est un de ses propres descendants (ça créerait un
|
||||
cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même)."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)",
|
||||
(element_id, new_parent_id),
|
||||
).fetchall()
|
||||
by_id = {r["id"]: r for r in rows}
|
||||
el = by_id.get(element_id)
|
||||
target = by_id.get(new_parent_id)
|
||||
if not el or not target or el["screen_id"] != target["screen_id"]:
|
||||
conn.close()
|
||||
return
|
||||
if target["widget"] not in CONTAINER_WIDGETS:
|
||||
conn.close()
|
||||
return
|
||||
if new_parent_id == element_id:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Cycle : new_parent_id ne doit pas être element_id, ni un descendant de
|
||||
# element_id (direct ou indirect) — sinon element_id finirait posé à
|
||||
# l'intérieur de sa propre arborescence.
|
||||
children_of = {}
|
||||
for r in conn.execute(
|
||||
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
|
||||
).fetchall():
|
||||
if r["parent_id"]:
|
||||
children_of.setdefault(r["parent_id"], []).append(r["id"])
|
||||
stack = list(children_of.get(element_id, []))
|
||||
descendants = set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node in descendants:
|
||||
continue
|
||||
descendants.add(node)
|
||||
stack.extend(children_of.get(node, []))
|
||||
if new_parent_id in descendants:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
|
||||
).fetchone()["m"]
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?",
|
||||
(new_parent_id, (max_z or 0) + 1, element_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def rename_element(slug, element_id, name):
|
||||
conn = db.connect(slug)
|
||||
conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,37 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def reorder_element(slug, screen_id, element_id, target_id, position):
|
||||
"""Déplace element_id juste avant ("before") ou juste après ("after")
|
||||
target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même
|
||||
parent_id — sinon on ne touche à rien, pour ne jamais reparenter par
|
||||
erreur via le glisser-déposer de l'arborescence). L'ordre est porté par
|
||||
z_index, partagé par tous les éléments de l'écran (pas juste les frères
|
||||
et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel
|
||||
(z_index, id) pour rester cohérent partout, pas seulement dans ce
|
||||
conteneur."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
|
||||
(screen_id,),
|
||||
).fetchall()
|
||||
by_id = {r["id"]: r["parent_id"] for r in rows}
|
||||
if element_id not in by_id or target_id not in by_id:
|
||||
conn.close()
|
||||
return
|
||||
if by_id[element_id] != by_id[target_id]:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
order = [r["id"] for r in rows]
|
||||
order.remove(element_id)
|
||||
target_index = order.index(target_id)
|
||||
insert_at = target_index + 1 if position == "after" else target_index
|
||||
order.insert(insert_at, element_id)
|
||||
|
||||
conn.executemany(
|
||||
"UPDATE _screen_elements SET z_index = ? WHERE id = ?",
|
||||
[(i + 1, eid) for i, eid in enumerate(order)],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,227 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
from ..clause_list_codec import encode_clauses
|
||||
from .get_element import get_element
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color):
|
||||
h = (hex_color or "").lstrip("#")
|
||||
if len(h) != 6:
|
||||
return (0, 0, 0)
|
||||
try:
|
||||
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def _is_default_value(control, raw_value):
|
||||
"""Compare la valeur reçue du formulaire à la valeur par défaut du
|
||||
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
|
||||
savoir si le champ a simplement été renvoyé tel quel sans être
|
||||
personnalisé."""
|
||||
default = control.get("default", "")
|
||||
if control["type"] == "slider":
|
||||
unit = control.get("unit", "")
|
||||
default = f"{default}{unit}" if default not in (None, "") else ""
|
||||
return str(raw_value) == str(default)
|
||||
|
||||
|
||||
def save_element_controls(slug, element_id, form):
|
||||
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
|
||||
par le panneau de propriétés (un seul formulaire, un champ par réglage)
|
||||
et met à jour content / attributs / style en conséquence."""
|
||||
el = get_element(slug, element_id)
|
||||
if not el:
|
||||
return
|
||||
meta = widget_meta(el["widget"])
|
||||
content = el["content"]
|
||||
attributes = dict(el["attributes"])
|
||||
style = dict(el["style"])
|
||||
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
|
||||
|
||||
for control in meta.get("controls", []):
|
||||
ctype = control["type"]
|
||||
field_name = "ctrl_" + control["key"]
|
||||
|
||||
if ctype == "toggle_token":
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
toggle_groups.setdefault(name, set())
|
||||
if form.get(field_name) is not None:
|
||||
toggle_groups[name].add(control["token"])
|
||||
continue
|
||||
|
||||
if ctype == "clause_list":
|
||||
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
|
||||
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
|
||||
# "_valeur[]"), reconstituées ici en une liste de clauses par
|
||||
# position (zip) — même principe que field_name[]/field_type[]
|
||||
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
|
||||
champs = form.getlist(field_name + "_champ[]")
|
||||
operateurs = form.getlist(field_name + "_operateur[]")
|
||||
valeurs = form.getlist(field_name + "_valeur[]")
|
||||
clauses = [
|
||||
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
|
||||
"valeur": valeurs[i] if i < len(valeurs) else ""}
|
||||
for i, champ in enumerate(champs)
|
||||
]
|
||||
encoded = encode_clauses(clauses)
|
||||
if encoded:
|
||||
attributes[control["clauses_attr"]] = encoded
|
||||
else:
|
||||
attributes.pop(control["clauses_attr"], None)
|
||||
combinateur = form.get(field_name + "_combinateur") or "et"
|
||||
if combinateur == "ou":
|
||||
attributes[control["combinator_attr"]] = combinateur
|
||||
else:
|
||||
attributes.pop(control["combinator_attr"], None)
|
||||
continue
|
||||
|
||||
if ctype == "preset":
|
||||
chosen = form.get(field_name, control.get("default"))
|
||||
opt = next((o for o in control["options"] if o[0] == chosen), None)
|
||||
if opt:
|
||||
for k, v in opt[2].items():
|
||||
if v in (None, ""):
|
||||
style.pop(k, None)
|
||||
else:
|
||||
style[k] = v
|
||||
if (
|
||||
control["key"] == "visibilite" and chosen == "visible"
|
||||
and el["widget"] not in LAYOUT_CAPABLE_WIDGETS
|
||||
):
|
||||
# "Visible" ne touche volontairement pas "display" (voir
|
||||
# visibility_control.py) : sur un widget qui A un
|
||||
# réglage "Disposition interne" (LAYOUT_CONTROLS,
|
||||
# traité avant celui-ci sur le MÊME enregistrement,
|
||||
# voir widget_meta.py), ce contrôle réaffirme lui-même
|
||||
# un "display" non-"none" (flex/etc), donc "Visible" n'a
|
||||
# rien à faire. Mais un widget SANS ce contrôle (ex.
|
||||
# "superposition") n'a PERSONNE d'autre pour l'effacer :
|
||||
# un "display:none" laissé par un "Masqué" précédent (ou
|
||||
# par le réglage par défaut à la création, voir
|
||||
# default_style_for_widget.py) restait donc bloqué
|
||||
# masqué pour toujours, quel que soit le nombre de fois
|
||||
# où "Visible" était ensuite choisi.
|
||||
style.pop("display", None)
|
||||
continue
|
||||
|
||||
if ctype == "scale":
|
||||
try:
|
||||
val = float(form.get(field_name, control.get("default", 1)))
|
||||
except (TypeError, ValueError):
|
||||
val = 1.0
|
||||
if abs(val - 1) < 1e-6:
|
||||
style.pop("transform", None)
|
||||
else:
|
||||
style["transform"] = f"scale({val})"
|
||||
continue
|
||||
|
||||
if ctype == "size":
|
||||
# Un seul champ CSS ("width"/"height") mais deux entrées de
|
||||
# formulaire (valeur + unité) — voir size_override_controls.py
|
||||
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
try:
|
||||
num = float(form.get(field_name + "_value") or 0)
|
||||
except (TypeError, ValueError):
|
||||
num = 0.0
|
||||
unit = form.get(field_name + "_unit", "%")
|
||||
if unit not in ("px", "%"):
|
||||
unit = "%"
|
||||
if num <= 0:
|
||||
style.pop(name, None)
|
||||
else:
|
||||
num_str = str(int(num)) if num == int(num) else str(num)
|
||||
style[name] = f"{num_str}{unit}"
|
||||
continue
|
||||
|
||||
if ctype == "shadow":
|
||||
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
|
||||
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
|
||||
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
|
||||
# <input type="color"> seul ne portant pas de canal alpha).
|
||||
_kind, name = control["target"].split(":", 1)
|
||||
defaults = control.get("default", {})
|
||||
|
||||
def _num(suffix, fallback):
|
||||
try:
|
||||
return float(form.get(field_name + suffix, fallback))
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
x = _num("_x", defaults.get("x", 0))
|
||||
y = _num("_y", defaults.get("y", 4))
|
||||
blur = _num("_blur", defaults.get("blur", 12))
|
||||
spread = _num("_spread", defaults.get("spread", 0))
|
||||
opacity = _num("_opacity", defaults.get("opacity", 0.4))
|
||||
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
|
||||
if x == 0 and y == 0 and blur == 0 and spread == 0:
|
||||
style.pop(name, None)
|
||||
else:
|
||||
r, g, b = _hex_to_rgb(color)
|
||||
|
||||
def _px(n):
|
||||
return str(int(n)) if n == int(n) else str(n)
|
||||
|
||||
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
|
||||
continue
|
||||
|
||||
target = control["target"]
|
||||
if ctype == "checkbox":
|
||||
checked = form.get(field_name) is not None
|
||||
raw_value = control["true_value"] if checked else control["false_value"]
|
||||
else:
|
||||
raw_value = form.get(field_name, control.get("default", ""))
|
||||
if ctype == "slider":
|
||||
unit = control.get("unit", "")
|
||||
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
|
||||
|
||||
if target == "content":
|
||||
content = raw_value
|
||||
else:
|
||||
kind, name = target.split(":", 1)
|
||||
if kind == "class":
|
||||
# "class:xxx" : un jeton de classe CSS externe (ex. un
|
||||
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
|
||||
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
|
||||
# voir _visible_attrs.py qui les rassemble avec la classe de
|
||||
# base du widget (fixed_attrs) au moment du rendu.
|
||||
store, name = attributes, "_class_" + name
|
||||
else:
|
||||
store = attributes if kind == "attr" else style
|
||||
if raw_value in (None, ""):
|
||||
store.pop(name, None)
|
||||
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
|
||||
# Ce réglage n'a jamais été personnalisé (la clé est absente
|
||||
# du style/des attributs stockés) et la valeur reçue est
|
||||
# simplement celle par défaut affichée dans le champ (un
|
||||
# <input type="color"> ou un slider a TOUJOURS une valeur à
|
||||
# l'écran, même quand rien n'a été choisi). Comme le
|
||||
# panneau de propriétés envoie TOUS les réglages du widget
|
||||
# en un seul formulaire, sauvegarder n'importe quel autre
|
||||
# champ (ex. changer la disposition) écrirait sinon cette
|
||||
# valeur par défaut "en dur" dans l'élément — ce qui faisait
|
||||
# apparaître par exemple une couleur de fond qui n'avait
|
||||
# jamais été demandée. On laisse donc ce réglage "non
|
||||
# défini" tant qu'il n'a pas été explicitement changé.
|
||||
pass
|
||||
else:
|
||||
store[name] = raw_value
|
||||
|
||||
for prop, tokens in toggle_groups.items():
|
||||
if tokens:
|
||||
style[prop] = " ".join(sorted(tokens))
|
||||
else:
|
||||
style.pop(prop, None)
|
||||
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
|
||||
(content, json.dumps(attributes), json.dumps(style), element_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,20 +0,0 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def set_personnage_data(slug, element_id, data):
|
||||
"""Fusionne _personnage_data (voir screens/rendering/personnage_data.py)
|
||||
dans les attributs déjà stockés de cet élément — remplace la source
|
||||
(Forge/personnalisée) et son contenu (personnage choisi, ou animations
|
||||
importées) en un seul appel, jamais les autres attributs de l'élément."""
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT attributes FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return
|
||||
attributes = json.loads(row["attributes"] or "{}")
|
||||
attributes["_personnage_data"] = json.dumps(data)
|
||||
conn.execute("UPDATE _screen_elements SET attributes = ? WHERE id = ?", (json.dumps(attributes), element_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,14 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def update_element_geometry(slug, element_id, x, y, width, height):
|
||||
"""Utilisé par le glisser-déposer / redimensionnement (et par les champs
|
||||
Position & taille du panneau de droite) — ne touche qu'à la position et
|
||||
à la taille, rien d'autre."""
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?",
|
||||
(x, y, width, height, element_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -11,7 +11,7 @@ def ensure_flow_schema(slug):
|
||||
node_type TEXT NOT NULL,
|
||||
x REAL NOT NULL DEFAULT 40,
|
||||
y REAL NOT NULL DEFAULT 40,
|
||||
trigger_element_id INTEGER REFERENCES _screen_elements(id),
|
||||
trigger_element_id INTEGER,
|
||||
trigger_event TEXT,
|
||||
cond_definition_id INTEGER,
|
||||
cond_row_id INTEGER,
|
||||
@@ -21,7 +21,7 @@ def ensure_flow_schema(slug):
|
||||
cond_value TEXT,
|
||||
action_type TEXT,
|
||||
target_screen_id INTEGER REFERENCES _screens(id),
|
||||
target_element_id INTEGER REFERENCES _screen_elements(id),
|
||||
target_element_id INTEGER,
|
||||
element_property TEXT,
|
||||
element_value TEXT,
|
||||
target_definition_id INTEGER,
|
||||
|
||||
@@ -1,66 +1,10 @@
|
||||
# ---------- Actions : modifier un élément ----------
|
||||
|
||||
ELEMENT_ACTION_PROPERTIES = [
|
||||
("visibilite", "Visibilité"),
|
||||
# Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un
|
||||
# créateur de reconstruire l'ancien comportement "texte affiché au
|
||||
# survol" (et bien d'autres) à la main dans la Logique de la scène —
|
||||
# poser "Modifier un élément → Contenu" sur un déclencheur "Au survol"
|
||||
# d'un AUTRE élément. La valeur est un texte littéral (pas encore de
|
||||
# binding vers un champ d'objet ici — voir applyOpenRowBindings pour le
|
||||
# mécanisme équivalent, propre à "Ouvrir la ligne cliquée").
|
||||
("contenu", "Contenu — texte affiché"),
|
||||
("bg_color", "Couleur de fond"),
|
||||
("border_color", "Couleur de bordure"),
|
||||
("width", "Largeur en %"),
|
||||
("height", "Hauteur en %"),
|
||||
# 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
# clignote autour de l'élément, pour attirer l'attention du joueur vers
|
||||
# ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) —
|
||||
# sans avoir à poser une bordure de couleur togglée à la main. Voir
|
||||
# ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html.
|
||||
("surbrillance", "Surbrillance — liseré clignotant"),
|
||||
# 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
|
||||
# ignore les clics, DISTINCT de "invisible" (qui garde sa place mais
|
||||
# disparaît visuellement) — un bouton "Répondre" qui reste visible mais
|
||||
# devient inerte une fois le mail traité, par exemple. Voir
|
||||
# ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans
|
||||
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
|
||||
# de se redéclencher tant que l'élément est désactivé).
|
||||
("desactive", "Désactivé — grisé, ignore les clics"),
|
||||
# Phase 5 (moteur) — déplacement d'élément : "absolu" fixe la position
|
||||
# (en % du cadre, comme Largeur/Hauteur ci-dessus — les éléments sont
|
||||
# déjà positionnés ainsi, voir filters/element_style_filter.py) ;
|
||||
# "relatif" AJOUTE la valeur à la position actuelle plutôt que de
|
||||
# l'écraser (voir applyElementProperty() dans static/js/play/actions.js)
|
||||
# — combiné à un déclencheur clavier (Phase 3), ça permet un
|
||||
# personnage/objet qui se déplace pas à pas à chaque appui de touche.
|
||||
("pos_x", "Position horizontale en % (absolue)"),
|
||||
("pos_y", "Position verticale en % (absolue)"),
|
||||
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
|
||||
("pos_y_relatif", "Déplacer verticalement de... % (relatif)"),
|
||||
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
|
||||
# l'élément (utile pour un personnage qui se déplace au clavier, voir
|
||||
# applyElementProperty() dans static/js/play/actions.js — retourne
|
||||
# l'élément en miroir plutôt que de nécessiter une 2e feuille de
|
||||
# sprites "vue de dos").
|
||||
("orientation", "Orientation — sens du personnage"),
|
||||
]
|
||||
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
|
||||
|
||||
ELEMENT_SURBRILLANCE_VALUES = [
|
||||
("on", "Activer la surbrillance"),
|
||||
("off", "Désactiver la surbrillance"),
|
||||
("toggle", "Basculer activée / désactivée"),
|
||||
]
|
||||
ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES)
|
||||
|
||||
ELEMENT_DESACTIVE_VALUES = [
|
||||
("on", "Désactiver — griser, ignorer les clics"),
|
||||
("off", "Réactiver"),
|
||||
("toggle", "Basculer désactivé / réactivé"),
|
||||
]
|
||||
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
|
||||
# ---------- Actions : orientation d'un objet de scène (jeu_2d) ----------
|
||||
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
|
||||
# l'objet (utile pour un personnage qui se déplace au clavier, voir
|
||||
# applyElementProperty() dans static/js/play/actions.js — retourne
|
||||
# l'élément en miroir plutôt que de nécessiter une 2e feuille de sprites
|
||||
# "vue de dos"). Repris par OBJECT_ACTION_PROPERTIES (screens/scenes/
|
||||
# flow_palette.py) et routes/scenes/scene_edit_view.py.
|
||||
|
||||
ELEMENT_ORIENTATION_VALUES = [
|
||||
("droite", "Faire face à droite"),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Ancien système d'actions par élément (_actions) — conservé pour ne rien
|
||||
casser sur les jeux déjà créés, remplacé par le module flow/."""
|
||||
@@ -1,25 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def add_action(
|
||||
slug, element_id, trigger_event, action_type, target_screen_id=None,
|
||||
target_element_id=None, element_property=None, element_value=None,
|
||||
target_definition_id=None, target_row_id=None, target_field=None,
|
||||
data_operation=None, data_value=None,
|
||||
):
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"""INSERT INTO _actions
|
||||
(element_id, trigger_event, action_type, target_screen_id,
|
||||
target_element_id, element_property, element_value,
|
||||
target_definition_id, target_row_id, target_field, data_operation, data_value)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
element_id, trigger_event, action_type, target_screen_id or None,
|
||||
target_element_id or None, element_property or None, element_value or None,
|
||||
target_definition_id or None, target_row_id or None, target_field or None,
|
||||
data_operation or None, data_value or None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def delete_action(slug, action_id):
|
||||
conn = db.connect(slug)
|
||||
conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def get_action(slug, action_id):
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -1,8 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def list_actions(slug, element_id):
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,12 +1,9 @@
|
||||
import db
|
||||
|
||||
from ..screens_repo.list_screens import list_screens
|
||||
from ..elements.list_elements import list_elements
|
||||
from ..flow.list_flow_nodes import list_flow_nodes
|
||||
from ..flow.list_flow_edges import list_flow_edges
|
||||
from ..animations.list_animation_clips import list_animation_clips
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
from ..rendering.personnage_data import resolve_personnage_animations
|
||||
from ..rendering.personnage_commands import resolve_personnage_commands
|
||||
from ..rendering.personnage_role import resolve_personnage_role
|
||||
@@ -45,59 +42,43 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
flows = {}
|
||||
animations = {}
|
||||
for s in screens_:
|
||||
if s["kind"] == "jeu_2d":
|
||||
objects = list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = render_scene_object(o)
|
||||
# Déplacement/animation automatiques (voir
|
||||
# static/js/play/personnage-controller.js) : chaque
|
||||
# personnage porte ses touches et son rôle déjà résolus
|
||||
# (défauts comblés) pour que le runtime client n'ait
|
||||
# aucune logique de repli à dupliquer — seul un
|
||||
# personnage "joueur" est déplacé/suivi par la caméra.
|
||||
if o["kind"] == "personnage":
|
||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||
o["personnage_role"] = resolve_personnage_role(o)
|
||||
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
|
||||
# Widgets d'interface (voir "🖥️ Interface",
|
||||
# screens/rendering/dialogue_box_style.py) : ni
|
||||
# collision, ni cible/source de règle — juste leur
|
||||
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||
continue
|
||||
# Boîte de collision (voir static/js/play/conditions.js::
|
||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||
# seulement "personnage" — un décor/fond peut aussi servir
|
||||
# d'obstacle.
|
||||
o["collision"] = resolve_collision_settings(o)
|
||||
# Règles "à la collision/dans un périmètre -> action" (voir
|
||||
# "🧩 Collision", templates/scene_edit.html, et
|
||||
# static/js/play/collision-rules-controller.js pour leur
|
||||
# exécution) — jamais sur "fond" (jamais une cible), ni sur
|
||||
# le personnage "joueur" lui-même (ce sont TOUJOURS les
|
||||
# AUTRES objets qui réagissent à SA présence, jamais
|
||||
# l'inverse).
|
||||
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
||||
o["collision_rules"] = resolve_collision_rules(o)
|
||||
payload_screens.append({**s, "elements": objects})
|
||||
else:
|
||||
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
||||
payload_screens.append({**s, "elements": elements})
|
||||
objects = list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = render_scene_object(o)
|
||||
# Déplacement/animation automatiques (voir
|
||||
# static/js/play/personnage-controller.js) : chaque
|
||||
# personnage porte ses touches et son rôle déjà résolus
|
||||
# (défauts comblés) pour que le runtime client n'ait
|
||||
# aucune logique de repli à dupliquer — seul un
|
||||
# personnage "joueur" est déplacé/suivi par la caméra.
|
||||
if o["kind"] == "personnage":
|
||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||
o["personnage_role"] = resolve_personnage_role(o)
|
||||
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
|
||||
# Widgets d'interface (voir "🖥️ Interface",
|
||||
# screens/rendering/dialogue_box_style.py) : ni
|
||||
# collision, ni cible/source de règle — juste leur
|
||||
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||
continue
|
||||
# Boîte de collision (voir static/js/play/conditions.js::
|
||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||
# seulement "personnage" — un décor/fond peut aussi servir
|
||||
# d'obstacle.
|
||||
o["collision"] = resolve_collision_settings(o)
|
||||
# Règles "à la collision/dans un périmètre -> action" (voir
|
||||
# "🧩 Collision", templates/scene_edit.html, et
|
||||
# static/js/play/collision-rules-controller.js pour leur
|
||||
# exécution) — jamais sur "fond" (jamais une cible), ni sur
|
||||
# le personnage "joueur" lui-même (ce sont TOUJOURS les
|
||||
# AUTRES objets qui réagissent à SA présence, jamais
|
||||
# l'inverse).
|
||||
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
||||
o["collision_rules"] = resolve_collision_rules(o)
|
||||
payload_screens.append({**s, "elements": objects})
|
||||
|
||||
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
||||
# écrans, écrans-modèles compris (include_templates=True) — pas
|
||||
# seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable
|
||||
# (ex. "mail content") peut avoir son propre déclencheur/sa propre
|
||||
# animation posée dans l'éditeur de SON écran-modèle (voir
|
||||
# screen_edit.html) : sans ses entrées ici, gameData.flows/
|
||||
# gameData.animations n'auraient jamais contenu cet écran-modèle côté
|
||||
# client, et findTriggerNode()/collectAnimationClips() (templates/
|
||||
# play.html) — pourtant déjà écrits pour les chercher — n'auraient
|
||||
# jamais rien trouvé. On ne les ajoute PAS à payload_screens : un
|
||||
# écran-modèle ne doit jamais être rendu comme un vrai <div
|
||||
# class="playScreen"> (il n'est jamais affiché tel quel, seulement
|
||||
# rechargé en direct à l'intérieur d'un élément qui l'utilise).
|
||||
# Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
|
||||
# écrans. Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
|
||||
# affichage de la page de jeu (donc toujours à partir du personnage
|
||||
# Forge ACTUELLEMENT assigné), plutôt que figées dans le nœud de flow/
|
||||
# le clip de Timeline au moment où le créateur les a configurés : sans
|
||||
@@ -107,24 +88,17 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# voir runActionNode()/applyAnimationClip() qui font cette résolution
|
||||
# à l'exécution à partir de gameData.personnage_animations plutôt que
|
||||
# depuis un data_value/custom_keyframes qui contiendrait des frames
|
||||
# déjà résolues). include_templates=True : un personnage peut vivre
|
||||
# dans un écran-modèle d'élément de jeu réutilisable, comme les flows/
|
||||
# animations ci-dessous.
|
||||
# déjà résolues).
|
||||
personnage_animations = {}
|
||||
for s in list_screens(slug, include_templates=True):
|
||||
for s in screens_:
|
||||
flows[str(s["id"])] = {
|
||||
"nodes": list_flow_nodes(slug, s["id"]),
|
||||
"edges": list_flow_edges(slug, s["id"]),
|
||||
}
|
||||
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
|
||||
if s["kind"] == "jeu_2d":
|
||||
for o in list_scene_objects(slug, s["id"]):
|
||||
if o["kind"] == "personnage":
|
||||
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
||||
else:
|
||||
for el in list_elements(slug, s["id"]):
|
||||
if el.get("widget") == "personnage":
|
||||
personnage_animations[str(el["id"])] = resolve_personnage_animations(el)
|
||||
for o in list_scene_objects(slug, s["id"]):
|
||||
if o["kind"] == "personnage":
|
||||
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
||||
|
||||
definitions = db.list_definitions(slug)
|
||||
data = {}
|
||||
@@ -164,30 +138,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
for f in full["fields"]
|
||||
]
|
||||
|
||||
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
|
||||
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
|
||||
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
|
||||
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
|
||||
# aussi être joués à l'affichage de cet écran — sans quoi une animation
|
||||
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
|
||||
# modèle est utilisé ailleurs.
|
||||
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
|
||||
|
||||
# Arbre complet (éléments + descendants) de l'écran-modèle de chaque
|
||||
# élément de jeu réutilisable — voir screens/element_types/
|
||||
# load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu
|
||||
# créateur ET jeu publié en ligne). Nécessaire ici pour que le port
|
||||
# client-side de l'export Web/SCORM (static/js/play/offline/,
|
||||
# rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de
|
||||
# Répéteur) puisse le rejouer sans base de données — inoffensif pour
|
||||
# le runtime en ligne, qui l'ignore.
|
||||
element_type_templates = {}
|
||||
for type_id_str, template_screen_id in element_types.items():
|
||||
if template_screen_id:
|
||||
tree = _load_template_tree(slug, int(type_id_str))
|
||||
if tree:
|
||||
element_type_templates[type_id_str] = tree
|
||||
|
||||
# Instantané des variables globales, pour qu'un nœud Condition puisse en
|
||||
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
||||
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
|
||||
@@ -216,8 +166,8 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
|
||||
return {
|
||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||
"data": data, "fields_meta": fields_meta,
|
||||
"variables": variables, "personnage_animations": personnage_animations,
|
||||
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
||||
"definition_names": definition_names,
|
||||
"quests": quests,
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
def _apply_ctx(text, ctx):
|
||||
"""À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par
|
||||
la valeur de ce champ pour la ligne en cours — appliqué au contenu ET
|
||||
aux attributs de l'élément et de tous ses descendants."""
|
||||
if not ctx or not text:
|
||||
return text
|
||||
for k, v in ctx.items():
|
||||
# "_forge_play_mode" (voir render_element_html.py) est un indicateur
|
||||
# interne posé dans ce même ctx, pas une valeur de champ {{...}} —
|
||||
# à ignorer ici.
|
||||
if k == "_forge_play_mode":
|
||||
continue
|
||||
text = text.replace("{{" + k + "}}", v)
|
||||
return text
|
||||
@@ -1,10 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
|
||||
def _attr_string(attrs, extra_style):
|
||||
parts = [f'style="{html_lib.escape(extra_style)}"']
|
||||
for k, v in attrs.items():
|
||||
if v in (None, "", False):
|
||||
continue
|
||||
parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"')
|
||||
return " ".join(parts)
|
||||
@@ -1,2 +0,0 @@
|
||||
def _auto_id(el):
|
||||
return f"elt-{el['id']}"
|
||||
@@ -1,199 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
|
||||
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
|
||||
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
|
||||
# convention de syntaxe que les {{champ}} déjà utilisés ailleurs (contenu
|
||||
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
|
||||
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
|
||||
|
||||
# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/)
|
||||
# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans
|
||||
# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend
|
||||
# toujours un point ("Objet.champ"). Le 2e groupe capture un CHEMIN
|
||||
# optionnel à l'intérieur de la variable ({{$perso.nom}}, {{$scores[0]}},
|
||||
# chaînable : {{$perso.arme.degats}}) — utile seulement pour une variable
|
||||
# de type "objet"/"tableau" (voir _resolve_variable_path ci-dessous), sans
|
||||
# effet sur une variable scalaire (chemin vide dans ce cas).
|
||||
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}\[\]]+)((?:\.[^.{}\[\]]+|\[\d+\])*)\s*\}\}$")
|
||||
|
||||
# Un segment de chemin est soit ".champ" (clé d'objet) soit "[index]"
|
||||
# (élément de tableau, 0 = premier) — voir _VAR_REF_PATTERN ci-dessus et le
|
||||
# contrôle "Chemin dans la variable" de la Condition de visibilité
|
||||
# (visibility_condition_controls.py).
|
||||
_PATH_SEGMENT_RE = re.compile(r"\.([^.\[\]]+)|\[(\d+)\]")
|
||||
|
||||
|
||||
def _resolve_variable_path(raw_value, path):
|
||||
"""Navigue dans une valeur JSON (chaîne stockée en base, colonne TEXT)
|
||||
selon un chemin ".champ"/"[index]" chaînable. Ne lève jamais : chemin
|
||||
vide -> valeur brute inchangée (utile pour une variable scalaire, où le
|
||||
chemin est toujours vide) ; JSON invalide, clé/index absent ou valeur
|
||||
intermédiaire pas navigable -> None — même convention que le reste de
|
||||
ce fichier (objet/variable introuvable -> ne matche rien plutôt que
|
||||
planter)."""
|
||||
if not path:
|
||||
return raw_value
|
||||
try:
|
||||
current = json.loads(raw_value) if raw_value else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for m in _PATH_SEGMENT_RE.finditer(path):
|
||||
key, index = m.group(1), m.group(2)
|
||||
try:
|
||||
current = current[key] if key is not None else current[int(index)]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _field_column(definition, field_name):
|
||||
"""Nom de la vraie colonne SQL d'un champ — un champ "relation" est
|
||||
stocké dans une colonne "<champ>_id" (voir create_definition.py), jamais
|
||||
sous son nom "propre" : sans ce cas particulier, filtrer ou comparer sur
|
||||
un champ relation ne trouvait jamais la bonne colonne et ne matchait
|
||||
donc jamais rien."""
|
||||
col = db.slugify(field_name).replace("-", "_")
|
||||
field = next((f for f in definition["fields"] if f["name"] == field_name), None)
|
||||
if field and field["type"] == "relation":
|
||||
col += "_id"
|
||||
return col
|
||||
|
||||
|
||||
def _resolve_filter_value(slug, raw_value, player_id=db.PLAYER_SHARED):
|
||||
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
|
||||
ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par
|
||||
convention, un objet utilisé comme "état de partie" — niveau courant,
|
||||
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place
|
||||
par des actions "Modifier une donnée" plutôt que d'en créer une
|
||||
nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
|
||||
lire la valeur ACTUELLE de cette variable globale (voir
|
||||
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe).
|
||||
`player_id` : état par joueur (Phase 1) — quel joueur consulte, pour
|
||||
qu'un objet/une variable per_player renvoie SA valeur (voir
|
||||
resolve_player_key.py/resolve_row_player_key.py, appelés en interne par
|
||||
get_global_variable/list_rows)."""
|
||||
if not raw_value:
|
||||
return raw_value
|
||||
stripped = raw_value.strip()
|
||||
|
||||
var_match = _VAR_REF_PATTERN.match(stripped)
|
||||
if var_match:
|
||||
variable = db.get_global_variable(slug, var_match.group(1).strip(), player_id)
|
||||
if not variable:
|
||||
return None # variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return _resolve_variable_path(variable.get("value"), var_match.group(2))
|
||||
|
||||
m = _REF_PATTERN.match(stripped)
|
||||
if not m:
|
||||
return raw_value
|
||||
object_name, field_name = m.group(1).strip(), m.group(2).strip()
|
||||
for d in db.list_definitions(slug):
|
||||
if d["name"] == object_name:
|
||||
full = db.get_definition(slug, d["id"])
|
||||
rows = db.list_rows(slug, full, player_id)
|
||||
if rows:
|
||||
return rows[0].get(_field_column(full, field_name))
|
||||
return None
|
||||
return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter
|
||||
|
||||
|
||||
def _compare(actual, operator, expected, field_type):
|
||||
"""Équivalent Python de compareValues() dans templates/play.html — les
|
||||
deux doivent rester alignées si on ajoute un opérateur."""
|
||||
if field_type == "booleen":
|
||||
# "actual" est un int SQLite (0/1) pour un champ d'objet, mais une
|
||||
# chaîne pour une variable globale (voir db/global_vars/, toujours
|
||||
# stockée en TEXT) — "0" est une chaîne non vide donc VRAIE en
|
||||
# Python, d'où ce traitement explicite plutôt qu'un simple bool(...).
|
||||
if isinstance(actual, str):
|
||||
truthy = actual.strip().lower() not in ("", "0", "false", "faux", "none")
|
||||
else:
|
||||
truthy = bool(actual)
|
||||
a = 1 if truthy else 0
|
||||
# "oui"/"non" est le vocabulaire affiché partout ailleurs dans
|
||||
# l'app pour un champ booléen (voir data_list.html, "Oui" si vrai
|
||||
# sinon "Non") — une valeur de comparaison fixe tapée "Oui" doit
|
||||
# donc être reconnue comme vraie ici aussi, pas seulement "1"/
|
||||
# "true"/"vrai" (sans quoi "Oui" retombait silencieusement à faux,
|
||||
# et un widget conditionné sur "est égal à Oui" ne s'affichait
|
||||
# jamais quand il aurait dû).
|
||||
e = 1 if str(expected).strip().lower() in ("1", "true", "vrai", "oui") else 0
|
||||
return a != e if operator == "different" else a == e
|
||||
try:
|
||||
an, en = float(actual), float(expected)
|
||||
numeric = True
|
||||
except (TypeError, ValueError):
|
||||
numeric = False
|
||||
if numeric:
|
||||
if operator == "egal":
|
||||
return an == en
|
||||
if operator == "different":
|
||||
return an != en
|
||||
if operator == "superieur":
|
||||
return an > en
|
||||
if operator == "inferieur":
|
||||
return an < en
|
||||
if operator == "superieur_egal":
|
||||
return an >= en
|
||||
if operator == "inferieur_egal":
|
||||
return an <= en
|
||||
return False
|
||||
as_, es_ = ("" if actual is None else str(actual)), ("" if expected is None else str(expected))
|
||||
if operator == "egal":
|
||||
return as_ == es_
|
||||
if operator == "different":
|
||||
return as_ != es_
|
||||
if operator == "superieur":
|
||||
return as_ > es_
|
||||
if operator == "inferieur":
|
||||
return as_ < es_
|
||||
if operator == "superieur_egal":
|
||||
return as_ >= es_
|
||||
if operator == "inferieur_egal":
|
||||
return as_ <= es_
|
||||
return False
|
||||
|
||||
|
||||
def _clause_matches(slug, definition, row, clause, player_id=db.PLAYER_SHARED):
|
||||
field_name = (clause.get("champ") or "").strip()
|
||||
if not field_name:
|
||||
return True
|
||||
operator = clause.get("operateur") or "egal"
|
||||
expected = _resolve_filter_value(slug, clause.get("valeur") or "", player_id)
|
||||
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
|
||||
col = _field_column(definition, field_name)
|
||||
return _compare(row.get(col), operator, expected, field_type)
|
||||
|
||||
|
||||
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id=db.PLAYER_SHARED):
|
||||
"""Ne garde, parmi les lignes, que celles qui satisfont la liste de
|
||||
clauses données (un nombre quelconque), combinées entre elles par ET
|
||||
(toutes vraies) ou par OU (au moins une vraie) selon `combinator`
|
||||
("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses
|
||||
reconstruites depuis _filtre_champ/_filtre2_champ, voir
|
||||
filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/
|
||||
Titre (clauses illimitées, voir resolve_bound_row.py)."""
|
||||
if not clauses:
|
||||
return rows
|
||||
if combinator == "ou":
|
||||
return [r for r in rows if any(_clause_matches(slug, definition, r, c, player_id) for c in clauses)]
|
||||
return [r for r in rows if all(_clause_matches(slug, definition, r, c, player_id) for c in clauses)]
|
||||
|
||||
|
||||
def filter_repeater_rows(slug, definition, rows, attrs, player_id=db.PLAYER_SHARED):
|
||||
"""Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent
|
||||
au(x) filtre(s) optionnel(s) réglé(s) dans ses propriétés (_filtre_champ/
|
||||
_filtre_operateur/_filtre_valeur, ET, si renseigné, _filtre2_champ/
|
||||
_filtre2_operateur/_filtre2_valeur combiné en ET avec le premier) — voir
|
||||
1.2 dans la doc de cadrage Forge Engine ("affichage piloté par la donnée
|
||||
sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher,
|
||||
par exemple, que le niveau du parcours en cours (2 conditions : le bon
|
||||
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
|
||||
clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
|
||||
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id)
|
||||
@@ -1,25 +1,17 @@
|
||||
from ..screens_repo.list_screens import list_screens
|
||||
from ..elements.list_elements import list_elements
|
||||
from ..scenes.list_scene_objects import list_scene_objects
|
||||
from .personnage_data import resolve_personnage_data
|
||||
|
||||
|
||||
def list_used_forge_characters(slug):
|
||||
"""Ensemble des slugs SPRITE_LIBRARY (screens.SPRITE_LIBRARY)
|
||||
réellement référencés par CE jeu — tous écrans, écrans-modèles compris
|
||||
(même patron que full_game_payload.py : un personnage peut vivre dans
|
||||
un écran-modèle d'élément réutilisable). Utilisé par
|
||||
réellement référencés par CE jeu — tous écrans confondus. Utilisé par
|
||||
publish/build_scorm_package.py pour ne copier dans le .zip exporté que les
|
||||
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
|
||||
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
|
||||
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
|
||||
used = set()
|
||||
for s in list_screens(slug, include_templates=True):
|
||||
for el in list_elements(slug, s["id"]):
|
||||
if el.get("widget") == "personnage":
|
||||
data = resolve_personnage_data(el)
|
||||
if data.get("source") == "forge" and data.get("forge_character"):
|
||||
used.add(data["forge_character"])
|
||||
for o in list_scene_objects(slug, s["id"]):
|
||||
if o.get("kind") == "personnage":
|
||||
data = resolve_personnage_data(o)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
def _parent_flex_direction(parent_el):
|
||||
"""Sens de la disposition interne d'un conteneur/répéteur/groupe de
|
||||
champs (voir le réglage "Disposition interne"), ou None s'il est en
|
||||
empilement libre (pas de display:flex) — utilisé pour adapter la taille
|
||||
par défaut de ses enfants (voir _style_string)."""
|
||||
style = (parent_el or {}).get("style") or {}
|
||||
if style.get("display") != "flex":
|
||||
return None
|
||||
return style.get("flex-direction") or "row"
|
||||
@@ -1,19 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
|
||||
|
||||
def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
input_type = "checkbox" if el.get("widget") == "case_a_cocher" else "radio"
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
attrs["type"] = input_type
|
||||
style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction)
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
# <label class="checkbox"/"radio"> entourant l'input EST la façon dont
|
||||
# Bulma stylise nativement une case à cocher/un bouton radio — pas de
|
||||
# wrapper supplémentaire nécessaire, la structure existante correspondait
|
||||
# déjà exactement à cette convention.
|
||||
return f'<label {_attr_string({"class": input_type}, style)}><input {_attr_string(attrs, "")}> {html_lib.escape(content)}</label>'
|
||||
@@ -1,13 +0,0 @@
|
||||
from .parent_flex_direction import _parent_flex_direction
|
||||
|
||||
|
||||
def _render_children(el, slug, children_map, ctx):
|
||||
"""Rendu récursif des éléments physiquement posés à l'intérieur d'un
|
||||
conteneur/répéteur/groupe de champs — chacun avec sa propre disposition,
|
||||
et le même contexte de ligne (ctx) que son parent s'il est dans un
|
||||
répéteur de données."""
|
||||
from .render_element_html import render_element_html
|
||||
|
||||
kids = (children_map or {}).get(el["id"], [])
|
||||
flex_dir = _parent_flex_direction(el)
|
||||
return "".join(render_element_html(c, slug, children_map, ctx, parent_flex_direction=flex_dir) for c in kids)
|
||||
@@ -1,220 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .render_children import _render_children
|
||||
from .render_table import _render_table
|
||||
from .render_select import _render_select
|
||||
from .render_fieldset import _render_fieldset
|
||||
from .render_checkbox_or_radio import _render_checkbox_or_radio
|
||||
from .render_repeater import _render_repeater
|
||||
from .render_jauge import _render_jauge
|
||||
from .render_overlay import _render_overlay
|
||||
from .render_onglets import _render_onglets
|
||||
from .render_icone import _render_icone
|
||||
from .render_personnage import _render_personnage
|
||||
from .resolve_bound_row import _resolve_bound_row_ctx
|
||||
from .visibility_condition import is_element_visible
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Construit le HTML réel (balise + attributs + style + contenu, y
|
||||
compris les éléments physiquement posés à l'intérieur pour un
|
||||
conteneur/répéteur/groupe de champs) — utilisé par l'éditeur (canevas)
|
||||
et le mode jouable. N'écrit jamais rien sur disque : uniquement du HTML
|
||||
généré à la volée à partir de ce qui est stocké en base. `slug` est
|
||||
nécessaire pour le Répéteur de données (il doit lire les données du jeu
|
||||
au moment de l'affichage). `ctx` porte les valeurs {{champ}} de la ligne
|
||||
en cours quand cet élément est descendant d'un répéteur. `parent_flex_direction`
|
||||
est la disposition interne (voir _parent_flex_direction) du conteneur qui
|
||||
pose physiquement cet élément, s'il y en a un — elle influence la largeur
|
||||
par défaut de cet élément (voir _style_string)."""
|
||||
meta = widget_meta(el.get("widget"))
|
||||
attrs_raw = el.get("attributes") or {}
|
||||
# La condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) n'est
|
||||
# appliquée qu'en mode JOUABLE (ctx["_forge_play_mode"], posé par
|
||||
# full_game_payload.py via list_elements(..., enforce_visibility=True))
|
||||
# — jamais dans l'éditeur, où l'élément doit rester visible/sélectionnable
|
||||
# même si la condition serait actuellement fausse, sans quoi il
|
||||
# deviendrait impossible de le retrouver pour le régler. Un commentaire
|
||||
# marqueur ("visibilityGated") reste posé sur le HTML même quand la
|
||||
# condition est vraie, pour que refreshRuntimeData() (play.html) sache
|
||||
# qu'il faut réévaluer cet élément après toute action "Modifier une
|
||||
# donnée"/"Modifier une variable" — pas seulement Jauge/Répéteur, qui ont
|
||||
# déjà chacun leur propre marqueur ("jaugeBar"/"repeaterItem") couvrant
|
||||
# ce cas d'office.
|
||||
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
|
||||
play_mode = bool((ctx or {}).get("_forge_play_mode"))
|
||||
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
|
||||
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
|
||||
|
||||
# "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu
|
||||
# ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du
|
||||
# rendu — potentiellement via une "Variable globale" en valeur de
|
||||
# comparaison (voir _resolve_filter_value). Comme "visibilityGated"
|
||||
# juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML pour
|
||||
# que refreshRuntimeData() (play.html) sache qu'il faut réévaluer cet
|
||||
# élément après toute action "Modifier une donnée"/"Modifier une
|
||||
# variable" — sans lui, changer la variable choisie changeait bien QUELLE
|
||||
# ligne aurait dû s'afficher (recalculé correctement côté serveur à
|
||||
# chaque rendu), mais le client ne redemandait jamais le nouveau HTML de
|
||||
# CET élément précis : le texte affiché restait figé sur son ancienne
|
||||
# ligne jusqu'au prochain rechargement complet de la page.
|
||||
has_data_binding = bool(attrs_raw.get("_data_definition_id"))
|
||||
|
||||
def _mark(html_str):
|
||||
markers = ""
|
||||
if has_visibility_condition:
|
||||
markers += "<!--visibilityGated-->"
|
||||
if has_data_binding:
|
||||
markers += "<!--dataBound-->"
|
||||
return html_str + markers
|
||||
|
||||
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw, (ctx or {}).get("_forge_player_id", "__shared__"))
|
||||
if bound_ctx:
|
||||
ctx = {**(ctx or {}), **bound_ctx}
|
||||
# Chaque branche "special_render" passe elle aussi par _mark() : sans
|
||||
# ça, un widget de ce genre (Icône, Tableau, Superposition...) actuellement
|
||||
# VISIBLE sous condition ne portait aucun marqueur, et refreshRuntimeData()
|
||||
# (play.html) ne savait donc jamais qu'il fallait le réévaluer après une
|
||||
# action "Modifier une donnée/variable" — il restait figé dans son état
|
||||
# de l'affichage précédent (ex: une icône "enveloppe fermée" restée
|
||||
# visible après l'ouverture du mail, en même temps que "enveloppe
|
||||
# ouverte" qui apparaît, elle, correctement puisqu'un élément CACHÉ
|
||||
# porte toujours son marqueur via le placeholder ci-dessus).
|
||||
special = meta.get("special_render")
|
||||
if special == "table":
|
||||
return _mark(_render_table(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "select_options":
|
||||
return _mark(_render_select(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "fieldset_legend":
|
||||
return _mark(_render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "checkbox_label":
|
||||
return _mark(_render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "repeater":
|
||||
return _mark(_render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "jauge":
|
||||
return _mark(_render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "overlay":
|
||||
return _mark(_render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "onglets":
|
||||
return _mark(_render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "icone":
|
||||
return _mark(_render_icone(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
if special == "personnage":
|
||||
return _mark(_render_personnage(el, meta, slug, children_map, ctx, parent_flex_direction))
|
||||
|
||||
tag = meta.get("tag")
|
||||
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
||||
niveau = (el.get("attributes") or {}).get("_niveau", "2")
|
||||
tag = f"h{niveau}"
|
||||
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
|
||||
# 3.1 (Confort) — interactions au survol : un texte de remplacement
|
||||
# optionnel, affiché à la place du contenu normal quand le joueur
|
||||
# survole cet élément à la souris (voir bindHoverTexts() dans
|
||||
# templates/play.html). Absent par défaut => aucun changement, donc
|
||||
# aucune régression sur les éléments déjà créés.
|
||||
hover_text = (el.get("attributes") or {}).get("_survol_texte")
|
||||
if hover_text:
|
||||
attrs["data-hover-text"] = _apply_ctx(hover_text, ctx)
|
||||
|
||||
if meta.get("self_closing"):
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
|
||||
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
child_html, is_overlay_template = (
|
||||
_render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False)
|
||||
)
|
||||
if child_html is None:
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
elif is_overlay_template:
|
||||
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
|
||||
# générique "conteneur" (add_element.py, "default_widget") — utile
|
||||
# pour la plupart des modèles, mais QUAND le modèle entier n'est
|
||||
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
|
||||
# de ce widget (widgets/registry.py "conteneur") lui donnait un
|
||||
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
|
||||
# déposé, alors que la superposition à l'intérieur gère déjà
|
||||
# entièrement sa propre apparence et son propre masquage
|
||||
# (position:fixed plein écran, démarre masquée) — vécu comme "un
|
||||
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
|
||||
# (en réalité la boîte de dialogue existe bien, juste masquée comme
|
||||
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
|
||||
# propre apparence visuelle).
|
||||
#
|
||||
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
|
||||
# précédent le faisait, en renvoyant `child_html` directement) :
|
||||
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
|
||||
# précis (id=el["id"], distinct de l'id interne du modèle,
|
||||
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
|
||||
# CET exemplaire est, par ex., 77) — une action "Modifier un
|
||||
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
|
||||
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
|
||||
# partagé par tous les exemplaires, du modèle. La court-circuiter
|
||||
# entièrement faisait disparaître cet id du DOM, rendant
|
||||
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
|
||||
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
|
||||
# visibilité de la modale depuis la scène"). On garde donc la
|
||||
# balise (avec son id), mais SANS la classe "box" du widget
|
||||
# générique — juste une balise transparente, invisible tant
|
||||
# qu'elle ne porte ni fond ni bordure."""
|
||||
attrs.pop("class", None)
|
||||
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
|
||||
|
||||
if tag in ("ul", "ol"):
|
||||
items = [line.strip() for line in content.split("\n") if line.strip()]
|
||||
inner = "".join(f"<li>{html_lib.escape(item)}</li>" for item in items) + child_html
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>{inner}</{tag}>")
|
||||
|
||||
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
|
||||
|
||||
|
||||
def _render_element_type_children(el, slug, ctx):
|
||||
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
|
||||
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
|
||||
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
|
||||
copiés en base — exactement le même principe qu'un modèle de ligne de
|
||||
Répéteur (voir _render_repeater/_load_template_tree). Modifier
|
||||
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
||||
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
||||
retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a
|
||||
plus de modèle valide, pour que l'appelant retombe sur le rendu
|
||||
générique (d'éventuels enfants en base issus d'une version antérieure
|
||||
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
|
||||
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
|
||||
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
|
||||
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
|
||||
apparence visuelle)."""
|
||||
template_tree = _load_template_tree(slug, el["element_type_id"])
|
||||
if not template_tree:
|
||||
return None, False
|
||||
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
|
||||
if is_overlay_only:
|
||||
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
|
||||
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
|
||||
# masquée par défaut, exactement comme la superposition elle-même)
|
||||
# — la superposition INTERNE au modèle ne doit donc plus appliquer
|
||||
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
|
||||
# ailleurs (sinon double masquage : rendre l'enveloppe visible
|
||||
# depuis la scène qui l'accueille resterait sans aucun effet
|
||||
# visible tant que la superposition interne, elle, reste "Masqué"
|
||||
# dans le modèle). On retire donc "display" de sa copie de style
|
||||
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
|
||||
# display:flex de base (render_overlay.py) s'applique alors.
|
||||
overlay_root = dict(template_tree["top"][0])
|
||||
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
|
||||
template_tree["top"][0] = overlay_root
|
||||
html = "".join(
|
||||
render_element_html(c, slug, template_tree["children_map"], ctx)
|
||||
for c in template_tree["top"]
|
||||
)
|
||||
return html, is_overlay_only
|
||||
@@ -1,15 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .apply_ctx import _apply_ctx
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from .render_children import _render_children
|
||||
|
||||
|
||||
def _render_fieldset(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget"))
|
||||
content = _apply_ctx(el.get("content") or "", ctx) or "Groupe de champs"
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
return f'<fieldset {_attr_string(attrs, style)}><legend>{html_lib.escape(content)}</legend>{child_html}</fieldset>'
|
||||
@@ -1,24 +0,0 @@
|
||||
from .visible_attrs import _visible_attrs
|
||||
from .style_string import _style_string
|
||||
from .attr_string import _attr_string
|
||||
from ..widgets.fontawesome_icons import FONTAWESOME_ICONS
|
||||
|
||||
|
||||
def _render_icone(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Widget Icône — un SVG affiché en silhouette via "mask-image" (voir
|
||||
.icon-svg dans style.css), pas une police d'icônes : certains
|
||||
navigateurs/extensions bloquent le chargement de polices web tierces
|
||||
(protection anti-fingerprinting), ce qui rendait les icônes Font Awesome
|
||||
invisibles quelle que soit leur source — un SVG posé en masque n'est
|
||||
jamais concerné. Se colore via "color" (couleur du texte, déjà un
|
||||
réglage existant), se dimensionne via "font-size" (le masque fait 1em)."""
|
||||
raw_attrs = el.get("attributes") or {}
|
||||
icon_slug = raw_attrs.get("_icon_slug") or "star"
|
||||
if icon_slug not in FONTAWESOME_ICONS:
|
||||
icon_slug = "star" # attribut corrompu/vidé : jamais d'icône cassée
|
||||
attrs = _visible_attrs(el, meta, ctx)
|
||||
existing_class = attrs.pop("class", "")
|
||||
attrs["class"] = (existing_class + " icon-svg").strip()
|
||||
icon_url = f"/static/icons/{icon_slug}.svg"
|
||||
style = _style_string(el, f" --icon-url:url('{icon_url}');", parent_flex_direction)
|
||||
return f"<i {_attr_string(attrs, style)}></i>"
|
||||
@@ -1,164 +0,0 @@
|
||||
import html as html_lib
|
||||
|
||||
import db
|
||||
|
||||
from .style_string import _style_string
|
||||
from .auto_id import _auto_id
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color):
|
||||
hex_color = (hex_color or "#5b8cff").lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
hex_color = "5b8cff"
|
||||
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _interpolate_color(low_hex, high_hex, ratio):
|
||||
"""Dégradé linéaire simple entre deux couleurs (rouge en bas, vert en
|
||||
haut par défaut, comme demandé dans le document d'interface de Boîte
|
||||
Mail) — ratio est déjà borné entre 0 et 1 par l'appelant."""
|
||||
lo, hi = _hex_to_rgb(low_hex), _hex_to_rgb(high_hex)
|
||||
mixed = tuple(round(lo[i] + (hi[i] - lo[i]) * ratio) for i in range(3))
|
||||
return "#{:02x}{:02x}{:02x}".format(*mixed)
|
||||
|
||||
|
||||
def _read_field_value(slug, definition_id, row_id, field_name, player_id=db.PLAYER_SHARED):
|
||||
"""Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de
|
||||
l'objet suivi — quand un objet contient plusieurs enregistrements (ex:
|
||||
Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est
|
||||
ce qui distingue lequel suivre. Repli sur la ligne la PLUS RÉCENTE si
|
||||
row_id est absent ou introuvable (jauges créées avant ce réglage, ou
|
||||
ligne supprimée depuis) — comportement historique, jamais de régression
|
||||
pour un objet à une seule ligne."""
|
||||
if not definition_id or not field_name or not slug:
|
||||
return None
|
||||
definition = db.get_definition(slug, int(definition_id))
|
||||
if not definition:
|
||||
return None
|
||||
rows = db.list_rows(slug, definition, player_id)
|
||||
if not rows:
|
||||
return None
|
||||
row = None
|
||||
if row_id:
|
||||
row = next((r for r in rows if str(r["id"]) == str(row_id)), None)
|
||||
if row is None:
|
||||
row = rows[0]
|
||||
col = db.slugify(field_name).replace("-", "_")
|
||||
return row.get(col)
|
||||
|
||||
|
||||
def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
"""Jauge/barre dont le remplissage (largeur en %) et la couleur (dégradé
|
||||
bas -> haut) se recalculent automatiquement à partir d'un champ
|
||||
numérique d'un objet de données — voir 2.1 dans la doc de cadrage Forge
|
||||
Engine ("binding live d'une donnée vers un style"). Recalculée à chaque
|
||||
régénération du HTML du jeu (chargement de /play, et après toute action
|
||||
"Modifier une donnée"), donc toujours à jour sans action supplémentaire
|
||||
à poser dans le graphe de logique."""
|
||||
attrs = el.get("attributes") or {}
|
||||
nom_position = attrs.get("_nom_position") or "dessus"
|
||||
nom_alignement = attrs.get("_nom_alignement") or "centre"
|
||||
nom_police = attrs.get("_nom_police") or "inherit"
|
||||
nom_taille = attrs.get("_nom_taille") or "12"
|
||||
nom_gras = attrs.get("_nom_gras") == "1"
|
||||
nom_italique = attrs.get("_nom_italique") == "1"
|
||||
# display:flex — le nom (optionnel) s'empile AU-DESSUS de la barre (mode
|
||||
# "dessus", flex-direction:column) ou se place à sa gauche (mode "cote",
|
||||
# flex-direction:row) plutôt que de se superposer dedans — voir name_html
|
||||
# plus bas. "justify-content:center" recentre le bloc nom+barre quand la
|
||||
# jauge se retrouve avec une hauteur supérieure à son contenu (ex. héritée
|
||||
# d'un conteneur parent qui l'étire) — sans ça le bloc restait collé en
|
||||
# haut de sa propre boîte, donnant une impression de centrage faussé dès
|
||||
# qu'un nom (donc plus de contenu) était ajouté. Le fond/bordure/overflow
|
||||
# vivent sur le wrapper de la barre (bar_wrap_style), pas ici, pour que le
|
||||
# nom reste en dehors de la zone rognée par "overflow:hidden".
|
||||
flex_dir = "row" if nom_position == "cote" else "column"
|
||||
align_items = "center" if nom_position == "cote" else "stretch"
|
||||
style = _style_string(
|
||||
el,
|
||||
f" display:flex; flex-direction:{flex_dir}; align-items:{align_items}; justify-content:center; gap:4px;",
|
||||
parent_flex_direction,
|
||||
)
|
||||
definition_id = attrs.get("_definition_id")
|
||||
row_id = attrs.get("_row_id")
|
||||
field_name = (attrs.get("_champ") or "").strip()
|
||||
field_name_nom = (attrs.get("_champ_nom") or "").strip()
|
||||
try:
|
||||
min_v = float(attrs.get("_min", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
min_v = 0.0
|
||||
try:
|
||||
max_v = float(attrs.get("_max", "100") or 100)
|
||||
except (TypeError, ValueError):
|
||||
max_v = 100.0
|
||||
couleur_basse = attrs.get("_couleur_basse") or "#e05252"
|
||||
couleur_haute = attrs.get("_couleur_haute") or "#4caf50"
|
||||
afficher_valeur = attrs.get("_afficher_valeur") == "1"
|
||||
|
||||
player_id = (ctx or {}).get("_forge_player_id", db.PLAYER_SHARED)
|
||||
raw_value = _read_field_value(slug, definition_id, row_id, field_name, player_id) if field_name else None
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
value = min_v
|
||||
|
||||
span = (max_v - min_v) or 1
|
||||
ratio = max(0.0, min(1.0, (value - min_v) / span))
|
||||
percent = round(ratio * 100, 1)
|
||||
fill_color = _interpolate_color(couleur_basse, couleur_haute, ratio)
|
||||
|
||||
label_html = ""
|
||||
if afficher_valeur:
|
||||
label_html = (
|
||||
f'<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
f'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
f'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">{html_lib.escape(str(raw_value if raw_value is not None else "—"))}</div>'
|
||||
)
|
||||
|
||||
name_html = ""
|
||||
if field_name_nom:
|
||||
nom_value = _read_field_value(slug, definition_id, row_id, field_name_nom, player_id)
|
||||
if nom_value not in (None, ""):
|
||||
text_align = "left" if (nom_position == "dessus" and nom_alignement == "gauche") else "center"
|
||||
name_html = (
|
||||
f'<div style="flex:0 0 auto; font-family:{html_lib.escape(nom_police)}; '
|
||||
f'font-size:{html_lib.escape(str(nom_taille))}px; '
|
||||
f'font-weight:{"700" if nom_gras else "400"}; '
|
||||
f'font-style:{"italic" if nom_italique else "normal"}; '
|
||||
f'color:#e8eaf0; text-align:{text_align};">{html_lib.escape(str(nom_value))}</div>'
|
||||
)
|
||||
|
||||
# Vraie balise <progress> Bulma ("class=progress") plutôt qu'une barre
|
||||
# maison faite de <div> positionnés — la couleur dégradée bas/haut (voir
|
||||
# _interpolate_color ci-dessus) reste possible nativement : Bulma pilote
|
||||
# justement le remplissage via la variable CSS --bulma-progress-value-
|
||||
# background-color, qu'on peut fixer en ligne à la couleur calculée sans
|
||||
# sortir du système de classes de Bulma. La classe "jaugeBar" est
|
||||
# repérée côté client (play.html, refreshRuntimeData) pour savoir que
|
||||
# cette balise doit être régénérée après toute action "Modifier une
|
||||
# donnée" — sinon la jauge resterait figée sur sa valeur de départ après
|
||||
# un changement, exactement comme il fallait déjà repérer "repeaterItem"
|
||||
# pour les Répéteurs (voir 1.1/1.2).
|
||||
extra_classes = " ".join(v for k, v in attrs.items() if k.startswith("_class_") and v)
|
||||
bar_class = html_lib.escape((f"progress jaugeBar {extra_classes}").strip())
|
||||
bar = (
|
||||
f'<progress class="{bar_class}" value="{percent}" max="100" '
|
||||
f'style="--bulma-progress-value-background-color:{fill_color}; --bulma-progress-bar-background-color:#1c2130; '
|
||||
f'margin:0; border-radius:6px; transition:background-color .3s ease;">{percent}%</progress>'
|
||||
)
|
||||
# min-height (pas 0) : un élément posé DANS un conteneur a une hauteur
|
||||
# "auto" par défaut (voir _style_string) — "flex:1 1 auto" seul n'a
|
||||
# alors rien à répartir (le conteneur flex lui-même n'a pas de hauteur
|
||||
# définie tant que rien ne la contraint) et la barre s'effondrait à 0px,
|
||||
# ne laissant plus que le nom visible. Cette valeur plancher garde la
|
||||
# barre visible dans ce cas, tout en la laissant grandir avec flex:1 si
|
||||
# l'élément a une vraie hauteur (posé directement sur un écran, ou
|
||||
# hauteur fixée via "Position & taille").
|
||||
bar_wrap = (
|
||||
f'<div style="position:relative; display:flex; align-items:center; '
|
||||
f'flex:1 1 auto; min-height:14px;">{bar}{label_html}</div>'
|
||||
)
|
||||
return (
|
||||
f'<div id="{_auto_id(el)}" data-element-id="{el["id"]}" style="{html_lib.escape(style)}">'
|
||||
f'{name_html}{bar_wrap}</div>'
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user