diff --git a/.gitignore b/.gitignore index 4b2ac312..9d4d5502 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/app.py b/app.py index d827796d..c7322838 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/core/jinja_filters.py b/core/jinja_filters.py index 184164a5..893eb49a 100644 --- a/core/jinja_filters.py +++ b/core/jinja_filters.py @@ -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 diff --git a/db/games/game_type_catalog.py b/db/games/game_type_catalog.py index b4308b73..89f4b7ba 100644 --- a/db/games/game_type_catalog.py +++ b/db/games/game_type_catalog.py @@ -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": ( + 'Un jeu 2D ludique,' + " sans une ligne de code" + '" + '🎓 Compatible LMS —' + " export SCORM en 1 clic" + ), "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, - }, } diff --git a/db/games/get_game_type.py b/db/games/get_game_type.py index 58162d1f..b214707c 100644 --- a/db/games/get_game_type.py +++ b/db/games/get_game_type.py @@ -1,6 +1,6 @@ from ..connection import connect -DEFAULT_GAME_TYPE = "document" +DEFAULT_GAME_TYPE = "jeu_2d" def get_game_type(slug): diff --git a/filters/element_style_filter.py b/filters/element_style_filter.py deleted file mode 100644 index c824ba82..00000000 --- a/filters/element_style_filter.py +++ /dev/null @@ -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 "" diff --git a/routes/__init__.py b/routes/__init__.py index 0a52327b..b25fb67d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/element_types/__init__.py b/routes/element_types/__init__.py deleted file mode 100644 index dadc6b56..00000000 --- a/routes/element_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import element_types, element_type_edit, element_type_delete diff --git a/routes/element_types/element_type_delete.py b/routes/element_types/element_type_delete.py deleted file mode 100644 index 1172c0bf..00000000 --- a/routes/element_types/element_type_delete.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import redirect, url_for - -import screens - -from core.flask_app import app - - -@app.route("/game//element-types//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")) diff --git a/routes/element_types/element_type_edit.py b/routes/element_types/element_type_edit.py deleted file mode 100644 index 75660849..00000000 --- a/routes/element_types/element_type_edit.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import request, redirect, url_for - -import screens - -from core.flask_app import app - - -@app.route("/game//element-types//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")) diff --git a/routes/element_types/element_types.py b/routes/element_types/element_types.py deleted file mode 100644 index f4f9fe6d..00000000 --- a/routes/element_types/element_types.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import request, redirect, url_for - -import screens - -from core.flask_app import app - - -@app.route("/game//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")) diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py deleted file mode 100644 index bbad48e1..00000000 --- a/routes/elements/__init__.py +++ /dev/null @@ -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, -) diff --git a/routes/elements/element_add.py b/routes/elements/element_add.py deleted file mode 100644 index 45cb24d0..00000000 --- a/routes/elements/element_add.py +++ /dev/null @@ -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//screens//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)) diff --git a/routes/elements/element_add_child.py b/routes/elements/element_add_child.py deleted file mode 100644 index ad819488..00000000 --- a/routes/elements/element_add_child.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/elements/element_delete.py b/routes/elements/element_delete.py deleted file mode 100644 index 2cdcda48..00000000 --- a/routes/elements/element_delete.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/elements/element_delete_impact.py b/routes/elements/element_delete_impact.py deleted file mode 100644 index 025adefe..00000000 --- a/routes/elements/element_delete_impact.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)}) diff --git a/routes/elements/element_duplicate.py b/routes/elements/element_duplicate.py deleted file mode 100644 index bca9841b..00000000 --- a/routes/elements/element_duplicate.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/elements/element_geometry.py b/routes/elements/element_geometry.py deleted file mode 100644 index f054fd37..00000000 --- a/routes/elements/element_geometry.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import request, jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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}) diff --git a/routes/elements/element_move_to_container.py b/routes/elements/element_move_to_container.py deleted file mode 100644 index 9b265527..00000000 --- a/routes/elements/element_move_to_container.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import request, jsonify, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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}) diff --git a/routes/elements/element_rename.py b/routes/elements/element_rename.py deleted file mode 100644 index c69fa754..00000000 --- a/routes/elements/element_rename.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/elements/element_reorder.py b/routes/elements/element_reorder.py deleted file mode 100644 index a3125505..00000000 --- a/routes/elements/element_reorder.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import request, jsonify, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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}) diff --git a/routes/elements/element_save.py b/routes/elements/element_save.py deleted file mode 100644 index 95e42d99..00000000 --- a/routes/elements/element_save.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/elements/element_set_personnage_data.py b/routes/elements/element_set_personnage_data.py deleted file mode 100644 index 881907d3..00000000 --- a/routes/elements/element_set_personnage_data.py +++ /dev/null @@ -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//elements//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)}) diff --git a/routes/elements/element_tab_add.py b/routes/elements/element_tab_add.py deleted file mode 100644 index 4dffd447..00000000 --- a/routes/elements/element_tab_add.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index a1c10fed..815c7c34 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -8,25 +8,12 @@ from core.flask_app import app @app.route("/game/") 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, - ) diff --git a/routes/legacy_actions/__init__.py b/routes/legacy_actions/__init__.py deleted file mode 100644 index 99f39ce3..00000000 --- a/routes/legacy_actions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import action_add, action_delete, action_run_data diff --git a/routes/legacy_actions/action_add.py b/routes/legacy_actions/action_add.py deleted file mode 100644 index 565a2c14..00000000 --- a/routes/legacy_actions/action_add.py +++ /dev/null @@ -1,35 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//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)) diff --git a/routes/legacy_actions/action_delete.py b/routes/legacy_actions/action_delete.py deleted file mode 100644 index 225172c5..00000000 --- a/routes/legacy_actions/action_delete.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//actions//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"])) diff --git a/routes/legacy_actions/action_run_data.py b/routes/legacy_actions/action_run_data.py deleted file mode 100644 index 2971f5e7..00000000 --- a/routes/legacy_actions/action_run_data.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//actions//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}) diff --git a/routes/objects/__init__.py b/routes/objects/__init__.py deleted file mode 100644 index 3d5052c3..00000000 --- a/routes/objects/__init__.py +++ /dev/null @@ -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, -) diff --git a/routes/objects/data_delete.py b/routes/objects/data_delete.py deleted file mode 100644 index e1cda644..00000000 --- a/routes/objects/data_delete.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import redirect, url_for, abort - -import db - -from core.flask_app import app - - -@app.route("/game//objects//data//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")) diff --git a/routes/objects/data_edit.py b/routes/objects/data_edit.py deleted file mode 100644 index 462887cd..00000000 --- a/routes/objects/data_edit.py +++ /dev/null @@ -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//objects//data//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) diff --git a/routes/objects/data_form.py b/routes/objects/data_form.py deleted file mode 100644 index 49e68601..00000000 --- a/routes/objects/data_form.py +++ /dev/null @@ -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")) diff --git a/routes/objects/data_new.py b/routes/objects/data_new.py deleted file mode 100644 index ccd2f97a..00000000 --- a/routes/objects/data_new.py +++ /dev/null @@ -1,7 +0,0 @@ -from core.flask_app import app -from .data_form import _data_form - - -@app.route("/game//objects//data/new", methods=["GET", "POST"]) -def data_new(slug, definition_id): - return _data_form(slug, definition_id, row=None) diff --git a/routes/objects/object_delete.py b/routes/objects/object_delete.py deleted file mode 100644 index 53623134..00000000 --- a/routes/objects/object_delete.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask import redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//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")) diff --git a/routes/objects/object_edit.py b/routes/objects/object_edit.py deleted file mode 100644 index 4ee56339..00000000 --- a/routes/objects/object_edit.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask import request, redirect, url_for, abort - -import db - -from core.flask_app import app - - -@app.route("/game//objects//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=).""" - 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)) diff --git a/routes/objects/object_field_add.py b/routes/objects/object_field_add.py deleted file mode 100644 index e383ab53..00000000 --- a/routes/objects/object_field_add.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import request, redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//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)) diff --git a/routes/objects/object_field_delete.py b/routes/objects/object_field_delete.py deleted file mode 100644 index c6247f77..00000000 --- a/routes/objects/object_field_delete.py +++ /dev/null @@ -1,11 +0,0 @@ -from flask import redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//fields//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)) diff --git a/routes/objects/object_field_edit.py b/routes/objects/object_field_edit.py deleted file mode 100644 index 8066a2e2..00000000 --- a/routes/objects/object_field_edit.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import request, redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//fields//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)) diff --git a/routes/objects/object_new.py b/routes/objects/object_new.py deleted file mode 100644 index 2a76a90c..00000000 --- a/routes/objects/object_new.py +++ /dev/null @@ -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//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)) diff --git a/routes/objects/object_view.py b/routes/objects/object_view.py deleted file mode 100644 index d05b504e..00000000 --- a/routes/objects/object_view.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import redirect, url_for - -from core.flask_app import app - - -@app.route("/game//objects/") -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/) — 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)) diff --git a/routes/objects/parse_field_rows.py b/routes/objects/parse_field_rows.py deleted file mode 100644 index 148aab27..00000000 --- a/routes/objects/parse_field_rows.py +++ /dev/null @@ -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 diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index f721b74a..70e01809 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -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, ) diff --git a/routes/scenes/collision_rule_objects.py b/routes/scenes/collision_rule_objects.py new file mode 100644 index 00000000..8fd72911 --- /dev/null +++ b/routes/scenes/collision_rule_objects.py @@ -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 diff --git a/routes/scenes/scene_collision_rule_objects.py b/routes/scenes/scene_collision_rule_objects.py new file mode 100644 index 00000000..1835b65b --- /dev/null +++ b/routes/scenes/scene_collision_rule_objects.py @@ -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//screens//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)) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 7ef00f2c..8e098a6c 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -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) diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py index e20131e9..86ec1a48 100644 --- a/routes/scenes/scene_object_add.py +++ b/routes/scenes/scene_object_add.py @@ -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//screens//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)) diff --git a/routes/scenes/scene_object_delete.py b/routes/scenes/scene_object_delete.py index e91058ee..2ba26765 100644 --- a/routes/scenes/scene_object_delete.py +++ b/routes/scenes/scene_object_delete.py @@ -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//scene-objects//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
, voir son + commentaire d'en-tête), ça remplaçait TOUT
(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)) diff --git a/routes/scenes/scene_object_names_list.py b/routes/scenes/scene_object_names_list.py new file mode 100644 index 00000000..92f63072 --- /dev/null +++ b/routes/scenes/scene_object_names_list.py @@ -0,0 +1,20 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//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)) diff --git a/routes/screens/__init__.py b/routes/screens/__init__.py index 8fd2d9f4..f1ab84df 100644 --- a/routes/screens/__init__.py +++ b/routes/screens/__init__.py @@ -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 diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 6e6de085..34f642f7 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -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) diff --git a/routes/screens/screen_set_aspect.py b/routes/screens/screen_set_aspect.py deleted file mode 100644 index d433cb95..00000000 --- a/routes/screens/screen_set_aspect.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import request, jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//screens//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}) diff --git a/routes/screens/screens_new.py b/routes/screens/screens_new.py index cab0bdc1..895a4fc3 100644 --- a/routes/screens/screens_new.py +++ b/routes/screens/screens_new.py @@ -9,18 +9,9 @@ from core.flask_app import app @app.route("/game//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)) diff --git a/screens/__init__.py b/screens/__init__.py index fb9463d1..96c7ac40 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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//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 diff --git a/screens/widgets/data_definition_options.py b/screens/data_definitions/data_definition_options.py similarity index 100% rename from screens/widgets/data_definition_options.py rename to screens/data_definitions/data_definition_options.py diff --git a/screens/element_types/__init__.py b/screens/element_types/__init__.py deleted file mode 100644 index 44558eab..00000000 --- a/screens/element_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...).""" diff --git a/screens/element_types/create_element_type.py b/screens/element_types/create_element_type.py deleted file mode 100644 index f1e4a49e..00000000 --- a/screens/element_types/create_element_type.py +++ /dev/null @@ -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() diff --git a/screens/element_types/delete_element_type.py b/screens/element_types/delete_element_type.py deleted file mode 100644 index 3e4c47b8..00000000 --- a/screens/element_types/delete_element_type.py +++ /dev/null @@ -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() diff --git a/screens/element_types/element_type_usage_count.py b/screens/element_types/element_type_usage_count.py deleted file mode 100644 index acfc7533..00000000 --- a/screens/element_types/element_type_usage_count.py +++ /dev/null @@ -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 diff --git a/screens/element_types/get_element_type_by_template_screen.py b/screens/element_types/get_element_type_by_template_screen.py deleted file mode 100644 index 1533cced..00000000 --- a/screens/element_types/get_element_type_by_template_screen.py +++ /dev/null @@ -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 diff --git a/screens/element_types/is_overlay_only.py b/screens/element_types/is_overlay_only.py deleted file mode 100644 index bc611662..00000000 --- a/screens/element_types/is_overlay_only.py +++ /dev/null @@ -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"} diff --git a/screens/element_types/list_element_types.py b/screens/element_types/list_element_types.py deleted file mode 100644 index f2ddc769..00000000 --- a/screens/element_types/list_element_types.py +++ /dev/null @@ -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 diff --git a/screens/element_types/load_template_tree.py b/screens/element_types/load_template_tree.py deleted file mode 100644 index 630984a4..00000000 --- a/screens/element_types/load_template_tree.py +++ /dev/null @@ -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} diff --git a/screens/element_types/update_element_type.py b/screens/element_types/update_element_type.py deleted file mode 100644 index 7ac380a1..00000000 --- a/screens/element_types/update_element_type.py +++ /dev/null @@ -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() diff --git a/screens/elements/__init__.py b/screens/elements/__init__.py deleted file mode 100644 index 8f3407b2..00000000 --- a/screens/elements/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Éléments posés sur un écran (_screen_elements).""" diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py deleted file mode 100644 index e1e99b1b..00000000 --- a/screens/elements/add_element.py +++ /dev/null @@ -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 diff --git a/screens/elements/add_tab.py b/screens/elements/add_tab.py deleted file mode 100644 index 8b0036d1..00000000 --- a/screens/elements/add_tab.py +++ /dev/null @@ -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 diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py deleted file mode 100644 index c187d7da..00000000 --- a/screens/elements/delete_element.py +++ /dev/null @@ -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() diff --git a/screens/elements/duplicate_element.py b/screens/elements/duplicate_element.py deleted file mode 100644 index ecd20e0b..00000000 --- a/screens/elements/duplicate_element.py +++ /dev/null @@ -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 diff --git a/screens/elements/element_descendant_ids.py b/screens/elements/element_descendant_ids.py deleted file mode 100644 index aa10b3cb..00000000 --- a/screens/elements/element_descendant_ids.py +++ /dev/null @@ -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 diff --git a/screens/elements/element_label.py b/screens/elements/element_label.py deleted file mode 100644 index 523132b5..00000000 --- a/screens/elements/element_label.py +++ /dev/null @@ -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", "?")) diff --git a/screens/elements/flow_nodes_referencing_element.py b/screens/elements/flow_nodes_referencing_element.py deleted file mode 100644 index 9231149a..00000000 --- a/screens/elements/flow_nodes_referencing_element.py +++ /dev/null @@ -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 diff --git a/screens/elements/get_element.py b/screens/elements/get_element.py deleted file mode 100644 index 03059904..00000000 --- a/screens/elements/get_element.py +++ /dev/null @@ -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 diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py deleted file mode 100644 index 38befc29..00000000 --- a/screens/elements/list_elements.py +++ /dev/null @@ -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 diff --git a/screens/elements/move_element_to_container.py b/screens/elements/move_element_to_container.py deleted file mode 100644 index 17e6bf67..00000000 --- a/screens/elements/move_element_to_container.py +++ /dev/null @@ -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() diff --git a/screens/elements/rename_element.py b/screens/elements/rename_element.py deleted file mode 100644 index 94026f46..00000000 --- a/screens/elements/rename_element.py +++ /dev/null @@ -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() diff --git a/screens/elements/reorder_element.py b/screens/elements/reorder_element.py deleted file mode 100644 index 5c926e56..00000000 --- a/screens/elements/reorder_element.py +++ /dev/null @@ -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() diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py deleted file mode 100644 index 2abb9587..00000000 --- a/screens/elements/save_element_controls.py +++ /dev/null @@ -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 - # 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 - # 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() diff --git a/screens/elements/set_personnage_data.py b/screens/elements/set_personnage_data.py deleted file mode 100644 index 93d61e94..00000000 --- a/screens/elements/set_personnage_data.py +++ /dev/null @@ -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() diff --git a/screens/elements/update_element_geometry.py b/screens/elements/update_element_geometry.py deleted file mode 100644 index f0155026..00000000 --- a/screens/elements/update_element_geometry.py +++ /dev/null @@ -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() diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index f776bf3e..2ac133d9 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -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, diff --git a/screens/labels/element_action_properties.py b/screens/labels/element_action_properties.py index 666236e0..8656110f 100644 --- a/screens/labels/element_action_properties.py +++ b/screens/labels/element_action_properties.py @@ -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"), diff --git a/screens/legacy_actions/__init__.py b/screens/legacy_actions/__init__.py deleted file mode 100644 index c718b1f2..00000000 --- a/screens/legacy_actions/__init__.py +++ /dev/null @@ -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/.""" diff --git a/screens/legacy_actions/add_action.py b/screens/legacy_actions/add_action.py deleted file mode 100644 index dab0e4f3..00000000 --- a/screens/legacy_actions/add_action.py +++ /dev/null @@ -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() diff --git a/screens/legacy_actions/delete_action.py b/screens/legacy_actions/delete_action.py deleted file mode 100644 index 39dc250c..00000000 --- a/screens/legacy_actions/delete_action.py +++ /dev/null @@ -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() diff --git a/screens/legacy_actions/get_action.py b/screens/legacy_actions/get_action.py deleted file mode 100644 index acb60484..00000000 --- a/screens/legacy_actions/get_action.py +++ /dev/null @@ -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 diff --git a/screens/legacy_actions/list_actions.py b/screens/legacy_actions/list_actions.py deleted file mode 100644 index ab9d8672..00000000 --- a/screens/legacy_actions/list_actions.py +++ /dev/null @@ -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] diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 327d16e9..47e98396 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -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
(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, } diff --git a/screens/rendering/apply_ctx.py b/screens/rendering/apply_ctx.py deleted file mode 100644 index 428b26a7..00000000 --- a/screens/rendering/apply_ctx.py +++ /dev/null @@ -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 diff --git a/screens/rendering/attr_string.py b/screens/rendering/attr_string.py deleted file mode 100644 index 5ef39d43..00000000 --- a/screens/rendering/attr_string.py +++ /dev/null @@ -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) diff --git a/screens/rendering/auto_id.py b/screens/rendering/auto_id.py deleted file mode 100644 index 334d2643..00000000 --- a/screens/rendering/auto_id.py +++ /dev/null @@ -1,2 +0,0 @@ -def _auto_id(el): - return f"elt-{el['id']}" diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py deleted file mode 100644 index ddd25f84..00000000 --- a/screens/rendering/filter_repeater_rows.py +++ /dev/null @@ -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 "_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) diff --git a/screens/rendering/list_used_forge_characters.py b/screens/rendering/list_used_forge_characters.py index 0b4394ab..004c3150 100644 --- a/screens/rendering/list_used_forge_characters.py +++ b/screens/rendering/list_used_forge_characters.py @@ -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/// 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) diff --git a/screens/rendering/parent_flex_direction.py b/screens/rendering/parent_flex_direction.py deleted file mode 100644 index c5ac0967..00000000 --- a/screens/rendering/parent_flex_direction.py +++ /dev/null @@ -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" diff --git a/screens/rendering/render_checkbox_or_radio.py b/screens/rendering/render_checkbox_or_radio.py deleted file mode 100644 index e97eddd8..00000000 --- a/screens/rendering/render_checkbox_or_radio.py +++ /dev/null @@ -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) - #