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"
+ '
'
+ '
✓'
+ "Personnages vivants — posez-les, donnez-leur"
+ " un rôle, ils bougent et réagissent seuls
"
+ '
✓'
+ "Quiz animés — intégrés à la scène,"
+ " correction immédiate
"
+ '🎓 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