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

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

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

Carte d'onboarding retravaillée : argumentaire RH non technique (liste à
coche, badge "Compatible LMS"), taille et interaction de retournement
ajustées.
This commit is contained in:
william
2026-09-04 20:56:32 +02:00
parent 00f7191a8b
commit 50835a18e2
221 changed files with 1239 additions and 12245 deletions
+1 -1
View File
@@ -1 +1 @@
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
-1
View File
@@ -1 +0,0 @@
from . import element_types, element_type_edit, element_type_delete
@@ -1,12 +0,0 @@
from flask import redirect, url_for
import screens
from core.flask_app import app
@app.route("/game/<slug>/element-types/<int:element_type_id>/delete", methods=["POST"])
def element_type_delete(slug, element_type_id):
if screens.element_type_usage_count(slug, element_type_id) == 0:
screens.delete_element_type(slug, element_type_id)
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
-16
View File
@@ -1,16 +0,0 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
@app.route("/game/<slug>/element-types/<int:element_type_id>/edit", methods=["POST"])
def element_type_edit(slug, element_type_id):
"""Renomme un élément de jeu et/ou change l'objet auquel il est lié —
son contenu (l'arbre d'éléments) se modifie séparément, via le lien
"✏️ Modifier le contenu" qui ouvre l'écran-modèle dans l'éditeur normal."""
name = request.form.get("name", "").strip() or None
bound_definition_id = request.form.get("bound_definition_id") or None
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
-22
View File
@@ -1,22 +0,0 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
def element_types(slug):
"""La création/gestion d'un élément de jeu se fait désormais depuis
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
création (barre de création repliable de cet onglet). Un GET direct
redirige vers le tableau de bord."""
if request.method == "POST":
name = request.form.get("name", "").strip()
icon = request.form.get("icon", "").strip() or "🧩"
bound_definition_id = request.form.get("bound_definition_id") or None
if name:
screens.create_element_type(slug, name, icon, bound_definition_id)
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
-14
View File
@@ -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,
)
-37
View File
@@ -1,37 +0,0 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
def element_add(slug, screen_id):
widget = request.form.get("widget", "conteneur")
from_catalogue = widget == "__catalogue__"
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
if from_catalogue and not element_type_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
if from_catalogue:
types = {t["id"]: t for t in screens.list_element_types(slug)}
et = types.get(int(element_type_id))
widget = et["default_widget"] if et else "conteneur"
new_id = screens.add_element(slug, screen_id, widget, element_type_id)
icon_slug = request.form.get("icon_class")
if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS:
# Posé depuis la galerie d'icônes (voir le panneau de propriétés,
# section "Icônes") : l'icône choisie remplace celle posée par
# défaut à la création plutôt que de repartir d'une icône neutre que
# l'utilisateur devrait ensuite changer manuellement.
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
if widget == "personnage":
# Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement()
# dans tree-panels.js) : remplace le personnage Forge par défaut
# (male-adventurer, voir default_style_for_widget.py) par celui
# réellement cliqué.
forge_character = request.form.get("forge_character")
if forge_character and forge_character in screens.SPRITE_LIBRARY:
forbid_admin_only_character(forge_character)
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
-25
View File
@@ -1,25 +0,0 @@
from flask import request, redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:parent_id>/children/add", methods=["POST"])
def element_add_child(slug, parent_id):
"""Ajoute un élément PHYSIQUEMENT à l'intérieur d'un conteneur, d'un
répéteur de données ou d'un groupe de champs déjà posé sur l'écran."""
parent = screens.get_element(slug, parent_id)
if not parent:
abort(404)
widget = request.form.get("widget", "conteneur")
from_catalogue = widget == "__catalogue__"
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
if from_catalogue and element_type_id:
types = {t["id"]: t for t in screens.list_element_types(slug)}
et = types.get(int(element_type_id))
widget = et["default_widget"] if et else "conteneur"
elif from_catalogue:
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=parent_id))
new_id = screens.add_element(slug, parent["screen_id"], widget, element_type_id, parent_id=parent_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=new_id))
-22
View File
@@ -1,22 +0,0 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/delete", methods=["POST"])
def element_delete(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
screen_id = element["screen_id"]
parent_id = element.get("parent_id")
screens.delete_element(slug, element_id)
# Resélectionne le conteneur parent (s'il y en a un) après suppression,
# plutôt que de laisser le panneau de droite vide : sinon, supprimer un
# élément fait brusquement "sauter" l'éditeur vers un état sans rien de
# sélectionné.
if parent_id:
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=parent_id))
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
-16
View File
@@ -1,16 +0,0 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
def element_delete_impact(slug, element_id):
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
Logique de la scène (déclencheur/action) seraient aussi supprimés en
même temps que l'élément — voir delete_element.py, qui les supprime
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
constraint failed", voir son commentaire)."""
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
-14
View File
@@ -1,14 +0,0 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
def element_duplicate(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
new_id = screens.duplicate_element(slug, element_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
-20
View File
@@ -1,20 +0,0 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/geometry", methods=["POST"])
def element_geometry(slug, element_id):
"""Appelé en AJAX pendant/à la fin d'un glisser-déposer, d'un
redimensionnement, ou d'une modification des champs Position & taille —
ne touche qu'à la position/taille, aucun rechargement de page nécessaire
pour que ça reste fluide."""
data = request.get_json(force=True)
screens.update_element_geometry(
slug, element_id,
x=float(data["x"]), y=float(data["y"]),
width=float(data["width"]), height=float(data["height"]),
)
return jsonify({"ok": True})
@@ -1,21 +0,0 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
def element_move_to_container(slug, element_id):
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
dessous — le fait passer enfant de ce conteneur, en dernière position
(voir move_element_to_container, qui refuse aussi silencieusement les
cas invalides : cible non-conteneur, ou cycle)."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
new_parent_id = int(data["parent_id"])
screens.move_element_to_container(slug, element_id, new_parent_id)
return jsonify({"ok": True})
-14
View File
@@ -1,14 +0,0 @@
from flask import request, redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/rename", methods=["POST"])
def element_rename(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
screens.rename_element(slug, element_id, request.form.get("name", ""))
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
-22
View File
@@ -1,22 +0,0 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
def element_reorder(slug, element_id):
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
des éléments d'un même conteneur — voir reorder_element."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
target_id = int(data["target_id"])
position = data.get("position")
if position not in ("before", "after"):
abort(400)
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
return jsonify({"ok": True})
-18
View File
@@ -1,18 +0,0 @@
from flask import request, redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/save", methods=["POST"])
def element_save(slug, element_id):
"""Un seul formulaire pour tout le panneau de propriétés : un champ par
réglage du widget (texte, case à cocher, curseur, couleur...) — c'est
screens.save_element_controls qui sait vers quel attribut ou quelle
propriété CSS chaque réglage doit être traduit."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
screens.save_element_controls(slug, element_id, request.form)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
@@ -1,24 +0,0 @@
from flask import request, jsonify
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
def element_set_personnage_data(slug, element_id):
"""Change le personnage Forge d'un élément déjà posé (galerie du
panneau de propriétés), ou met à jour ses animations personnalisées
(import de frames) — voir personnage-panel.js. Renvoie les animations
résolues pour que le client relance immédiatement l'aperçu animé sans
recharger la page (voir personnage-preview.js)."""
body = request.get_json(silent=True) or {}
el = screens.get_element(slug, element_id)
if not el:
return jsonify({"error": "élément introuvable"}), 404
if body.get("source") == "forge" and body.get("forge_character"):
forbid_admin_only_character(body["forge_character"])
screens.set_personnage_data(slug, element_id, body)
el = screens.get_element(slug, element_id)
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
-14
View File
@@ -1,14 +0,0 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
def element_tab_add(slug, element_id):
onglets = screens.get_element(slug, element_id)
if not onglets:
abort(404)
screens.add_tab(slug, element_id, onglets["screen_id"])
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
+14 -71
View File
@@ -8,25 +8,12 @@ from core.flask_app import app
@app.route("/game/<slug>")
def game_dashboard(slug):
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
accès direct à chaque élément — la barre de navigation du jeu (voir
base.html) permet déjà de sauter directement vers chaque éditeur, cette
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
liens.
Onboarding guidé (voir db/games/game_type_catalog.py) : un compte
"restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE —
juste ses écrans, en cartes avec un aperçu de leur contenu, plus un
bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le
projet : "jeu_2d" pour un RPG, "document" pour les autres — voir
routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/
variables restent réservés à "Créer mon jeu de A à Z", la seule
formule pensée pour la puissance complète du moteur."""
"""Vue d'ensemble du jeu : juste ses écrans, en cartes avec un aperçu
de leur contenu, plus un bouton "+ Ajouter un écran" qui ne demande pas
de type (un seul moteur d'écran : jeu_2d — voir
routes/screens/screens_new.py)."""
game = db.game_meta(slug)
if db.is_restricted(slug):
return _render_simple_dashboard(slug, game)
return _render_full_dashboard(slug, game)
return _render_simple_dashboard(slug, game)
def _render_simple_dashboard(slug, game):
@@ -37,61 +24,17 @@ def _render_simple_dashboard(slug, game):
objects = screens.list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
else:
# Taille du MONDE (voir screens.resolve_scene_world_size,
# même calcul que l'éditeur, routes/scenes/scene_edit_view.py)
# plutôt que scene_width/scene_height (la caméra nominale) :
# un "fond" plus grand que la caméra dépassait alors le cadre
# de la miniature (game_dashboard_simple.html), qui n'en
# montrait que le coin haut-gauche au lieu de la scène entière.
world_width, world_height = screens.resolve_scene_world_size(s, objects)
previews[s["id"]] = {
"kind": "document",
"elements": screens.list_elements(slug, s["id"]),
"kind": "jeu_2d", "objects": objects,
"world_width": world_width, "world_height": world_height,
}
return render_template(
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
)
def _render_full_dashboard(slug, game):
definitions = db.list_definitions(slug)
# Détail complet (avec ses champs) de chaque objet, pour le panneau
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
# l'ouverture (voir openObjectEditPanel()).
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
# objet, les libellés lisibles de ses champs relation (pour l'affichage
# du tableau) et les options possibles de ces mêmes champs relation
# (pour le formulaire "Ajouter une entrée").
rows_by_definition = {}
relation_labels_by_definition = {}
relation_options_by_definition = {}
referenced_by_definition = {}
for d in definitions:
full = definitions_full[d["id"]]
rows_by_definition[d["id"]] = db.list_rows(slug, full)
relation_labels = {}
relation_options = {}
for f in full["fields"]:
if f["type"] == "relation":
options = db.relation_options(slug, f["relation_definition_id"])
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
relation_options[f["id"]] = options
relation_labels_by_definition[d["id"]] = relation_labels
relation_options_by_definition[d["id"]] = relation_options
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
screen_list = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
variables = db.list_global_variables(slug)
return render_template(
"game_dashboard.html", game=game, definitions=definitions,
definitions_full=definitions_full,
rows_by_definition=rows_by_definition,
relation_labels_by_definition=relation_labels_by_definition,
relation_options_by_definition=relation_options_by_definition,
referenced_by_definition=referenced_by_definition,
screen_list=screen_list, element_types=element_types,
element_type_usage=element_type_usage, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
)
-1
View File
@@ -1 +0,0 @@
from . import action_add, action_delete, action_run_data
-35
View File
@@ -1,35 +0,0 @@
from flask import request, redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/actions/add", methods=["POST"])
def action_add(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
trigger_event = screens._trigger_for(element["widget"], element["attributes"])
action_type = request.form.get("action_type", "rien")
def _int_or_none(key):
raw = request.form.get(key)
try:
return int(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
screens.add_action(
slug, element_id, trigger_event, action_type,
target_screen_id=_int_or_none("target_screen_id"),
target_element_id=_int_or_none("target_element_id"),
element_property=request.form.get("element_property") or None,
element_value=request.form.get("element_value") or None,
target_definition_id=_int_or_none("target_definition_id"),
target_row_id=_int_or_none("target_row_id"),
target_field=request.form.get("target_field") or None,
data_operation=request.form.get("data_operation") or None,
data_value=request.form.get("data_value") or None,
)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
-15
View File
@@ -1,15 +0,0 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/actions/<int:action_id>/delete", methods=["POST"])
def action_delete(slug, action_id):
action = screens.get_action(slug, action_id)
if not action:
abort(404)
element = screens.get_element(slug, action["element_id"])
screens.delete_action(slug, action_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element["id"]))
-17
View File
@@ -1,17 +0,0 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/actions/<int:action_id>/run-data", methods=["POST"])
def action_run_data(slug, action_id):
"""Exécuté côté serveur au moment du clic, en mode jouable, pour une
action "Modifier une donnée d'un objet" — les actions "Modifier un
élément" restent, elles, purement côté client (juste du style CSS)."""
action = screens.get_action(slug, action_id)
if not action or action.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400
ok = screens.apply_data_action(slug, action)
return jsonify({"ok": ok})
-14
View File
@@ -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,
)
-21
View File
@@ -1,21 +0,0 @@
from flask import redirect, url_for, abort
import db
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/delete", methods=["POST"])
def data_delete(slug, definition_id, row_id):
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
referencing = db.rows_referencing(slug, definition_id, row_id)
if referencing:
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
# une relation. On revient au panneau de cet objet (onglet
# Données), qui explique pourquoi via un paramètre de requête
# simple plutôt qu'une session/flash message.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
db.delete_row(slug, definition, row_id)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
-17
View File
@@ -1,17 +0,0 @@
from flask import abort
import db
from core.flask_app import app
from .data_form import _data_form
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/edit", methods=["GET", "POST"])
def data_edit(slug, definition_id, row_id):
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
row = db.get_row(slug, definition, row_id)
if not row:
abort(404)
return _data_form(slug, definition_id, row=row)
-24
View File
@@ -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"))
-7
View File
@@ -1,7 +0,0 @@
from core.flask_app import app
from .data_form import _data_form
@app.route("/game/<slug>/objects/<int:definition_id>/data/new", methods=["GET", "POST"])
def data_new(slug, definition_id):
return _data_form(slug, definition_id, row=None)
-19
View File
@@ -1,19 +0,0 @@
from flask import redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/delete", methods=["POST"])
def object_delete(slug, definition_id):
referenced_by = db.definitions_referencing(slug, definition_id)
if referenced_by:
# Suppression bloquée : d'autres objets ont une relation vers
# celui-ci. On revient à son panneau, qui affiche déjà cet
# avertissement (referenced_by_definition, voir game_dashboard.py)
# en permanence, pas seulement après une tentative — plutôt que de
# casser ces relations silencieusement.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
db.delete_definition(slug, definition_id)
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
-24
View File
@@ -1,24 +0,0 @@
from flask import request, redirect, url_for, abort
import db
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
def object_edit(slug, definition_id):
"""Renommer un objet + gérer ses champs se fait désormais depuis un
panneau déplaçable/redimensionnable sur le tableau de bord (voir
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
qu'une page dédiée — cette route ne fait donc plus que traiter le
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
le panneau de cet objet (?edit=<id>)."""
if request.method == "POST":
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
new_name = request.form.get("object_name", "").strip()
if new_name and new_name != definition["name"]:
db.rename_definition(slug, definition_id, new_name)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-26
View File
@@ -1,26 +0,0 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/fields/add", methods=["POST"])
def object_field_add(slug, definition_id):
name = request.form.get("field_name", "").strip()
field_type = request.form.get("field_type", "texte")
relation_definition_id = request.form.get("field_relation")
# Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet
# cible valide ferait planter add_field_to_definition (int("") lève),
# ce qui annulait l'ALTER TABLE avant son commit() -- le champ
# n'était alors ajouté du tout, sans le moindre message d'erreur.
if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()):
db.add_field_to_definition(slug, definition_id, {
"name": name,
"type": field_type,
"relation_definition_id": relation_definition_id,
"required": request.form.get("field_required") == "1",
"min_value": request.form.get("field_min") or None,
"max_value": request.form.get("field_max") or None,
})
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-11
View File
@@ -1,11 +0,0 @@
from flask import redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
def object_field_delete(slug, definition_id, field_id):
db.delete_field(slug, definition_id, field_id)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-20
View File
@@ -1,20 +0,0 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/edit", methods=["POST"])
def object_field_edit(slug, definition_id, field_id):
new_name = request.form.get("field_name", "").strip()
if new_name:
db.update_field(
slug, definition_id, field_id,
new_name=new_name,
required=request.form.get("field_required") == "1",
relation_definition_id=request.form.get("field_relation"),
min_value=request.form.get("field_min") or None,
max_value=request.form.get("field_max") or None,
)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-35
View File
@@ -1,35 +0,0 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
from .parse_field_rows import _parse_field_rows
@app.route("/game/<slug>/objects/new", methods=["GET", "POST"])
def object_new(slug):
"""La création d'un objet se fait désormais depuis un panneau déplaçable
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
externe obsolète) redirige simplement vers le tableau de bord."""
if request.method == "POST":
name = request.form.get("object_name", "").strip()
# Aucun champ requis à la création : create_definition(fields=[])
# crée simplement une table sans colonne "métier" (juste id/
# created_at), et le panneau "Modifier un objet" (onglet Champs,
# bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins
# un champ ici faisait échouer silencieusement la création (aucun
# message d'erreur, juste un retour au tableau de bord) dès qu'on
# tapait le nom sans avoir encore cliqué "+ Ajouter un champ".
fields = _parse_field_rows(request.form)
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
# explicitement rend l'objet partagé par tous les joueurs — ex. un
# catalogue/une liste de questions — voir db/definitions/
# create_definition.py).
per_player = request.form.get("per_player", "1") == "1"
if name:
definition_id = db.create_definition(slug, name, fields, per_player=per_player)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug))
-18
View File
@@ -1,18 +0,0 @@
from flask import redirect, url_for
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>")
def object_view(slug, definition_id):
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
retirée : les données vivent désormais dans l'onglet "Données" du
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
route reste néanmoins en place, comme pur redirecteur, pour deux
raisons : un lien externe/ancien vers cette URL continue de mener
quelque part de sensé, et surtout beaucoup de tests (et certains
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
segment du chemin renvoyé par le header Location d'une redirection
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
termine par l'id), qui redirige ensuite vers le tableau de bord."""
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-36
View File
@@ -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
+2
View File
@@ -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,
)
+61
View File
@@ -0,0 +1,61 @@
import screens
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
def build_collision_rule_objects(objects):
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
screens/rendering/collision_rules.py) — factorisé ici plutôt que
dupliqué : utilisé au premier rendu de la page (routes/scenes/
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
scene-editor.js) pour reconstruire la liste SANS recharger la page.
`objects` doit déjà porter "rendered_html" (voir
screens.render_scene_object) sur chaque élément.
resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans
rôle explicitement enregistré (voir screens/rendering/
personnage_role.py — comportement voulu pour une scène à un seul
personnage, posé avant l'ajout de ce réglage). Appliqué tel quel,
ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite
(le cas de toute scène créée avant ce réglage — un personnage ajouté
depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite,
voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et
disparaîtraient donc TOUS de cette liste. On ne considère donc comme
"joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le
plus ancien, z_index croissant comme `objects`) — les suivants sans
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
base."""
implicit_joueur_claimed = any(
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
for o in objects if o["kind"] == "personnage"
)
result = []
for o in objects:
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
continue
role = None
if o["kind"] == "personnage":
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
if explicit_role in screens.PERSONNAGE_ROLES:
role = explicit_role
elif not implicit_joueur_claimed:
role = "joueur"
implicit_joueur_claimed = True
else:
role = "pnj"
if role == "joueur":
continue
result.append({
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
})
return result
@@ -0,0 +1,24 @@
from flask import jsonify
import screens
from core.flask_app import app
from .collision_rule_objects import build_collision_rule_objects
@app.route("/game/<slug>/screens/<int:screen_id>/collision-rule-objects", methods=["GET"])
def scene_collision_rule_objects(slug, screen_id):
"""Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS,
templates/scene_edit.html) — appelée en AJAX après un ajout/suppression
d'objet, ou un changement de rôle/nom (voir static/js/scenes/
scene-editor.js) qui peut faire apparaître/disparaître un objet de
cette liste (rôle passé à/depuis "joueur", voir
build_collision_rule_objects) ou changer son libellé affiché (nom).
Recalculée entièrement plutôt que diffée : la liste reste petite (un
objet de scène par carte, jamais un "fond"/widget d'UI) et cette même
fonction est déjà appelée à chaque rendu complet de la page."""
objects = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
return jsonify(build_collision_rule_objects(objects))
+13 -33
View File
@@ -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)
+30 -2
View File
@@ -1,4 +1,4 @@
from flask import request, redirect, url_for
from flask import request, redirect, url_for, jsonify
import screens
@@ -10,7 +10,20 @@ from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_back
def scene_object_add(slug, screen_id):
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
element_add.py, mais pour _scene_objects (voir screens/scenes/
add_scene_object.py)."""
add_scene_object.py).
Appelée en AJAX (voir addSceneObject(), static/js/scenes/scene-editor.js)
plutôt qu'en soumission de formulaire classique — sans ça, poser un
objet rechargeait TOUTE la page : perceptible comme un "vidage" de
l'éditeur de collision/quêtes le temps du rechargement, et perdait tout
état JS en cours (modale ouverte, etc.). Répond en JSON avec de quoi
ajouter l'objet au canevas SANS recharger — jamais sélectionné/ouvert
dans le panneau de propriétés pour autant (ça, voir
onSceneObjectMouseDown, reste un rechargement pour l'instant). La liste
"🧩 Collision" n'est PAS incluse ici : addSceneObject() la redemande
ensuite à routes/scenes/scene_collision_rule_objects.py (même endpoint
que pour un changement de rôle/nom, ou une suppression) plutôt que de
dupliquer ici la logique de build_collision_rule_objects()."""
kind = request.form.get("kind", "personnage")
forge_character = request.form.get("forge_character")
background_slug = request.form.get("background_slug")
@@ -21,7 +34,22 @@ def scene_object_add(slug, screen_id):
new_id = screens.add_scene_object(
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
)
if request.headers.get("X-Requested-With") == "fetch":
obj = screens.get_scene_object(slug, new_id)
return jsonify({
"id": new_id, "kind": obj["kind"],
"x": obj["x"], "y": obj["y"], "width": obj["width"], "height": obj["height"],
"rendered_html": screens.render_scene_object(obj),
# Relance l'aperçu animé tout de suite (voir personnage-preview.js
# ::restartPersonnageIdlePreview) — sans ça, un personnage
# fraîchement posé restait figé sur sa première frame jusqu'au
# prochain rechargement de page (qui n'arrive plus jamais, voir
# addSceneObject() côté JS).
"animations": screens.resolve_personnage_animations(obj) if obj["kind"] == "personnage" else None,
})
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
# Conservé pour tout appelant non-JS (aucun connu actuellement, mais
# ne coûte rien à garder en secours).
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
+16 -1
View File
@@ -1,4 +1,4 @@
from flask import redirect, url_for
from flask import redirect, request, url_for, jsonify
import screens
@@ -7,9 +7,24 @@ from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
def scene_object_delete(slug, object_id):
"""Supprime un objet de scène — bouton 🗑️ du panneau de propriétés
(voir templates/scene_edit.html), toujours pour l'objet SÉLECTIONNÉ (le
seul dont ce bouton est affiché).
Appelée en AJAX (deleteSceneObject(), static/js/scenes/scene-editor.js)
plutôt qu'en soumission de formulaire classique — sinon même
"rechargé en douceur" par pjax.js (qui intercepte tout <form>, voir son
commentaire d'en-tête), ça remplaçait TOUT <main> (canevas + onglets
Collision/Quêtes/Événements), perceptible comme un "vidage" de
l'éditeur le temps du remplacement."""
obj = screens.get_scene_object(slug, object_id)
if not obj:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False}), 404
return redirect(url_for("index"))
screen_id = obj["scene_id"]
screens.delete_scene_object(slug, object_id)
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True, "id": object_id})
# Secours pour tout appelant non-JS (aucun connu actuellement).
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
+20
View File
@@ -0,0 +1,20 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-object-names", methods=["GET"])
def scene_object_names_list(slug):
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
templates/scene_edit.html, questSpeakerOptions() dans static/js/
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
chargement complet de la page (HORS de #builder3, jamais rafraîchie
par une sélection d'objet sans rechargement, voir goToSelected()) :
sans cet endpoint, renommer un personnage puis rouvrir la modale de
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
temps — appelée en AJAX depuis saveSceneObjectName()."""
return jsonify(screens.list_named_scene_object_names(slug))
+1 -1
View File
@@ -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
+4 -124
View File
@@ -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)
-17
View File
@@ -1,17 +0,0 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/screens/<int:screen_id>/aspect", methods=["POST"])
def screen_set_aspect(slug, screen_id):
"""Mémorise le format d'aperçu (Portrait/Paysage/Carré) choisi pour cet
écran, en AJAX — sans recharger la page, pour ne jamais perdre ce choix
au prochain ajout/suppression d'élément ou changement de sélection (qui
redemandent le panneau au serveur)."""
body = request.get_json(silent=True) or {}
aspect = body.get("aspect", "")
screens.set_screen_aspect(slug, screen_id, aspect)
return jsonify({"ok": True})
+3 -12
View File
@@ -9,18 +9,9 @@ from core.flask_app import app
@app.route("/game/<slug>/screens/new", methods=["POST"])
def screens_new(slug):
name = request.form.get("name", "").strip()
if db.is_restricted(slug):
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
# imposé par le projet (jeu_2d pour un RPG, document pour les
# autres), même valeur pour TOUS ses écrans (voir
# db/games/game_type_catalog.py).
onboarding_type = db.get_onboarding_type(slug)
kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
else:
kind = request.form.get("kind", "document")
if kind not in ("document", "jeu_2d"):
kind = "document"
# Un seul moteur d'écran désormais (jeu_2d) — imposé à tout écran créé,
# quel que soit le projet (voir db/games/game_type_catalog.py).
kind = "jeu_2d"
if name:
screen_id = screens.create_screen(slug, name, kind=kind)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))