Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées.
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import element_types, element_type_edit, element_type_delete
|
||||
@@ -1,12 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/element-types/<int:element_type_id>/delete", methods=["POST"])
|
||||
def element_type_delete(slug, element_type_id):
|
||||
if screens.element_type_usage_count(slug, element_type_id) == 0:
|
||||
screens.delete_element_type(slug, element_type_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
@@ -1,16 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/element-types/<int:element_type_id>/edit", methods=["POST"])
|
||||
def element_type_edit(slug, element_type_id):
|
||||
"""Renomme un élément de jeu et/ou change l'objet auquel il est lié —
|
||||
son contenu (l'arbre d'éléments) se modifie séparément, via le lien
|
||||
"✏️ Modifier le contenu" qui ouvre l'écran-modèle dans l'éditeur normal."""
|
||||
name = request.form.get("name", "").strip() or None
|
||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
|
||||
def element_types(slug):
|
||||
"""La création/gestion d'un élément de jeu se fait désormais depuis
|
||||
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
|
||||
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
|
||||
création (barre de création repliable de cet onglet). Un GET direct
|
||||
redirige vers le tableau de bord."""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
icon = request.form.get("icon", "").strip() or "🧩"
|
||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||
if name:
|
||||
screens.create_element_type(slug, name, icon, bound_definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
||||
@@ -1,14 +0,0 @@
|
||||
from . import (
|
||||
element_add,
|
||||
element_add_child,
|
||||
element_geometry,
|
||||
element_reorder,
|
||||
element_move_to_container,
|
||||
element_save,
|
||||
element_delete,
|
||||
element_delete_impact,
|
||||
element_duplicate,
|
||||
element_rename,
|
||||
element_tab_add,
|
||||
element_set_personnage_data,
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from core.sprite_gate import forbid_admin_only_character
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
|
||||
def element_add(slug, screen_id):
|
||||
widget = request.form.get("widget", "conteneur")
|
||||
from_catalogue = widget == "__catalogue__"
|
||||
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
||||
if from_catalogue and not element_type_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
if from_catalogue:
|
||||
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
widget = et["default_widget"] if et else "conteneur"
|
||||
new_id = screens.add_element(slug, screen_id, widget, element_type_id)
|
||||
icon_slug = request.form.get("icon_class")
|
||||
if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS:
|
||||
# Posé depuis la galerie d'icônes (voir le panneau de propriétés,
|
||||
# section "Icônes") : l'icône choisie remplace celle posée par
|
||||
# défaut à la création plutôt que de repartir d'une icône neutre que
|
||||
# l'utilisateur devrait ensuite changer manuellement.
|
||||
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
|
||||
if widget == "personnage":
|
||||
# Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement()
|
||||
# dans tree-panels.js) : remplace le personnage Forge par défaut
|
||||
# (male-adventurer, voir default_style_for_widget.py) par celui
|
||||
# réellement cliqué.
|
||||
forge_character = request.form.get("forge_character")
|
||||
if forge_character and forge_character in screens.SPRITE_LIBRARY:
|
||||
forbid_admin_only_character(forge_character)
|
||||
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||
@@ -1,25 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:parent_id>/children/add", methods=["POST"])
|
||||
def element_add_child(slug, parent_id):
|
||||
"""Ajoute un élément PHYSIQUEMENT à l'intérieur d'un conteneur, d'un
|
||||
répéteur de données ou d'un groupe de champs déjà posé sur l'écran."""
|
||||
parent = screens.get_element(slug, parent_id)
|
||||
if not parent:
|
||||
abort(404)
|
||||
widget = request.form.get("widget", "conteneur")
|
||||
from_catalogue = widget == "__catalogue__"
|
||||
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
||||
if from_catalogue and element_type_id:
|
||||
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
widget = et["default_widget"] if et else "conteneur"
|
||||
elif from_catalogue:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=parent_id))
|
||||
new_id = screens.add_element(slug, parent["screen_id"], widget, element_type_id, parent_id=parent_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=new_id))
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/delete", methods=["POST"])
|
||||
def element_delete(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
screen_id = element["screen_id"]
|
||||
parent_id = element.get("parent_id")
|
||||
screens.delete_element(slug, element_id)
|
||||
# Resélectionne le conteneur parent (s'il y en a un) après suppression,
|
||||
# plutôt que de laisser le panneau de droite vide : sinon, supprimer un
|
||||
# élément fait brusquement "sauter" l'éditeur vers un état sans rien de
|
||||
# sélectionné.
|
||||
if parent_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=parent_id))
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
@@ -1,16 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
|
||||
def element_delete_impact(slug, element_id):
|
||||
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
|
||||
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
|
||||
Logique de la scène (déclencheur/action) seraient aussi supprimés en
|
||||
même temps que l'élément — voir delete_element.py, qui les supprime
|
||||
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
|
||||
constraint failed", voir son commentaire)."""
|
||||
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
|
||||
def element_duplicate(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
new_id = screens.duplicate_element(slug, element_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
|
||||
@@ -1,20 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/geometry", methods=["POST"])
|
||||
def element_geometry(slug, element_id):
|
||||
"""Appelé en AJAX pendant/à la fin d'un glisser-déposer, d'un
|
||||
redimensionnement, ou d'une modification des champs Position & taille —
|
||||
ne touche qu'à la position/taille, aucun rechargement de page nécessaire
|
||||
pour que ça reste fluide."""
|
||||
data = request.get_json(force=True)
|
||||
screens.update_element_geometry(
|
||||
slug, element_id,
|
||||
x=float(data["x"]), y=float(data["y"]),
|
||||
width=float(data["width"]), height=float(data["height"]),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,21 +0,0 @@
|
||||
from flask import request, jsonify, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
|
||||
def element_move_to_container(slug, element_id):
|
||||
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
|
||||
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
|
||||
dessous — le fait passer enfant de ce conteneur, en dernière position
|
||||
(voir move_element_to_container, qui refuse aussi silencieusement les
|
||||
cas invalides : cible non-conteneur, ou cycle)."""
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
data = request.get_json(force=True)
|
||||
new_parent_id = int(data["parent_id"])
|
||||
screens.move_element_to_container(slug, element_id, new_parent_id)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/rename", methods=["POST"])
|
||||
def element_rename(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
screens.rename_element(slug, element_id, request.form.get("name", ""))
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||
@@ -1,22 +0,0 @@
|
||||
from flask import request, jsonify, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
|
||||
def element_reorder(slug, element_id):
|
||||
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
|
||||
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
|
||||
des éléments d'un même conteneur — voir reorder_element."""
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
data = request.get_json(force=True)
|
||||
target_id = int(data["target_id"])
|
||||
position = data.get("position")
|
||||
if position not in ("before", "after"):
|
||||
abort(400)
|
||||
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,18 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/save", methods=["POST"])
|
||||
def element_save(slug, element_id):
|
||||
"""Un seul formulaire pour tout le panneau de propriétés : un champ par
|
||||
réglage du widget (texte, case à cocher, curseur, couleur...) — c'est
|
||||
screens.save_element_controls qui sait vers quel attribut ou quelle
|
||||
propriété CSS chaque réglage doit être traduit."""
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
screens.save_element_controls(slug, element_id, request.form)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
from core.sprite_gate import forbid_admin_only_character
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
|
||||
def element_set_personnage_data(slug, element_id):
|
||||
"""Change le personnage Forge d'un élément déjà posé (galerie du
|
||||
panneau de propriétés), ou met à jour ses animations personnalisées
|
||||
(import de frames) — voir personnage-panel.js. Renvoie les animations
|
||||
résolues pour que le client relance immédiatement l'aperçu animé sans
|
||||
recharger la page (voir personnage-preview.js)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
el = screens.get_element(slug, element_id)
|
||||
if not el:
|
||||
return jsonify({"error": "élément introuvable"}), 404
|
||||
if body.get("source") == "forge" and body.get("forge_character"):
|
||||
forbid_admin_only_character(body["forge_character"])
|
||||
screens.set_personnage_data(slug, element_id, body)
|
||||
el = screens.get_element(slug, element_id)
|
||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
|
||||
def element_tab_add(slug, element_id):
|
||||
onglets = screens.get_element(slug, element_id)
|
||||
if not onglets:
|
||||
abort(404)
|
||||
screens.add_tab(slug, element_id, onglets["screen_id"])
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
|
||||
@@ -8,25 +8,12 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>")
|
||||
def game_dashboard(slug):
|
||||
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
|
||||
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
|
||||
accès direct à chaque élément — la barre de navigation du jeu (voir
|
||||
base.html) permet déjà de sauter directement vers chaque éditeur, cette
|
||||
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
|
||||
liens.
|
||||
|
||||
Onboarding guidé (voir db/games/game_type_catalog.py) : un compte
|
||||
"restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE —
|
||||
juste ses écrans, en cartes avec un aperçu de leur contenu, plus un
|
||||
bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le
|
||||
projet : "jeu_2d" pour un RPG, "document" pour les autres — voir
|
||||
routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/
|
||||
variables restent réservés à "Créer mon jeu de A à Z", la seule
|
||||
formule pensée pour la puissance complète du moteur."""
|
||||
"""Vue d'ensemble du jeu : juste ses écrans, en cartes avec un aperçu
|
||||
de leur contenu, plus un bouton "+ Ajouter un écran" qui ne demande pas
|
||||
de type (un seul moteur d'écran : jeu_2d — voir
|
||||
routes/screens/screens_new.py)."""
|
||||
game = db.game_meta(slug)
|
||||
if db.is_restricted(slug):
|
||||
return _render_simple_dashboard(slug, game)
|
||||
return _render_full_dashboard(slug, game)
|
||||
return _render_simple_dashboard(slug, game)
|
||||
|
||||
|
||||
def _render_simple_dashboard(slug, game):
|
||||
@@ -37,61 +24,17 @@ def _render_simple_dashboard(slug, game):
|
||||
objects = screens.list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
|
||||
else:
|
||||
# Taille du MONDE (voir screens.resolve_scene_world_size,
|
||||
# même calcul que l'éditeur, routes/scenes/scene_edit_view.py)
|
||||
# plutôt que scene_width/scene_height (la caméra nominale) :
|
||||
# un "fond" plus grand que la caméra dépassait alors le cadre
|
||||
# de la miniature (game_dashboard_simple.html), qui n'en
|
||||
# montrait que le coin haut-gauche au lieu de la scène entière.
|
||||
world_width, world_height = screens.resolve_scene_world_size(s, objects)
|
||||
previews[s["id"]] = {
|
||||
"kind": "document",
|
||||
"elements": screens.list_elements(slug, s["id"]),
|
||||
"kind": "jeu_2d", "objects": objects,
|
||||
"world_width": world_width, "world_height": world_height,
|
||||
}
|
||||
return render_template(
|
||||
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
|
||||
)
|
||||
|
||||
|
||||
def _render_full_dashboard(slug, game):
|
||||
definitions = db.list_definitions(slug)
|
||||
# Détail complet (avec ses champs) de chaque objet, pour le panneau
|
||||
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
|
||||
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
|
||||
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
|
||||
# l'ouverture (voir openObjectEditPanel()).
|
||||
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
|
||||
|
||||
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
|
||||
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
|
||||
# objet, les libellés lisibles de ses champs relation (pour l'affichage
|
||||
# du tableau) et les options possibles de ces mêmes champs relation
|
||||
# (pour le formulaire "Ajouter une entrée").
|
||||
rows_by_definition = {}
|
||||
relation_labels_by_definition = {}
|
||||
relation_options_by_definition = {}
|
||||
referenced_by_definition = {}
|
||||
for d in definitions:
|
||||
full = definitions_full[d["id"]]
|
||||
rows_by_definition[d["id"]] = db.list_rows(slug, full)
|
||||
relation_labels = {}
|
||||
relation_options = {}
|
||||
for f in full["fields"]:
|
||||
if f["type"] == "relation":
|
||||
options = db.relation_options(slug, f["relation_definition_id"])
|
||||
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
|
||||
relation_options[f["id"]] = options
|
||||
relation_labels_by_definition[d["id"]] = relation_labels
|
||||
relation_options_by_definition[d["id"]] = relation_options
|
||||
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
|
||||
|
||||
screen_list = screens.list_screens(slug)
|
||||
element_types = screens.list_element_types(slug)
|
||||
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
|
||||
variables = db.list_global_variables(slug)
|
||||
return render_template(
|
||||
"game_dashboard.html", game=game, definitions=definitions,
|
||||
definitions_full=definitions_full,
|
||||
rows_by_definition=rows_by_definition,
|
||||
relation_labels_by_definition=relation_labels_by_definition,
|
||||
relation_options_by_definition=relation_options_by_definition,
|
||||
referenced_by_definition=referenced_by_definition,
|
||||
screen_list=screen_list, element_types=element_types,
|
||||
element_type_usage=element_type_usage, variables=variables,
|
||||
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from . import action_add, action_delete, action_run_data
|
||||
@@ -1,35 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/actions/add", methods=["POST"])
|
||||
def action_add(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
trigger_event = screens._trigger_for(element["widget"], element["attributes"])
|
||||
action_type = request.form.get("action_type", "rien")
|
||||
|
||||
def _int_or_none(key):
|
||||
raw = request.form.get(key)
|
||||
try:
|
||||
return int(raw) if raw not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
screens.add_action(
|
||||
slug, element_id, trigger_event, action_type,
|
||||
target_screen_id=_int_or_none("target_screen_id"),
|
||||
target_element_id=_int_or_none("target_element_id"),
|
||||
element_property=request.form.get("element_property") or None,
|
||||
element_value=request.form.get("element_value") or None,
|
||||
target_definition_id=_int_or_none("target_definition_id"),
|
||||
target_row_id=_int_or_none("target_row_id"),
|
||||
target_field=request.form.get("target_field") or None,
|
||||
data_operation=request.form.get("data_operation") or None,
|
||||
data_value=request.form.get("data_value") or None,
|
||||
)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||
@@ -1,15 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/actions/<int:action_id>/delete", methods=["POST"])
|
||||
def action_delete(slug, action_id):
|
||||
action = screens.get_action(slug, action_id)
|
||||
if not action:
|
||||
abort(404)
|
||||
element = screens.get_element(slug, action["element_id"])
|
||||
screens.delete_action(slug, action_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element["id"]))
|
||||
@@ -1,17 +0,0 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/actions/<int:action_id>/run-data", methods=["POST"])
|
||||
def action_run_data(slug, action_id):
|
||||
"""Exécuté côté serveur au moment du clic, en mode jouable, pour une
|
||||
action "Modifier une donnée d'un objet" — les actions "Modifier un
|
||||
élément" restent, elles, purement côté client (juste du style CSS)."""
|
||||
action = screens.get_action(slug, action_id)
|
||||
if not action or action.get("action_type") != "modifier_donnee":
|
||||
return jsonify({"ok": False}), 400
|
||||
ok = screens.apply_data_action(slug, action)
|
||||
return jsonify({"ok": ok})
|
||||
@@ -1,14 +0,0 @@
|
||||
from . import (
|
||||
parse_field_rows,
|
||||
object_new,
|
||||
object_view,
|
||||
object_edit,
|
||||
object_field_add,
|
||||
object_field_edit,
|
||||
object_field_delete,
|
||||
object_delete,
|
||||
data_form,
|
||||
data_new,
|
||||
data_edit,
|
||||
data_delete,
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/delete", methods=["POST"])
|
||||
def data_delete(slug, definition_id, row_id):
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
referencing = db.rows_referencing(slug, definition_id, row_id)
|
||||
if referencing:
|
||||
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
|
||||
# une relation. On revient au panneau de cet objet (onglet
|
||||
# Données), qui explique pourquoi via un paramètre de requête
|
||||
# simple plutôt qu'une session/flash message.
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
|
||||
db.delete_row(slug, definition, row_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
||||
@@ -1,17 +0,0 @@
|
||||
from flask import abort
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
from .data_form import _data_form
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/edit", methods=["GET", "POST"])
|
||||
def data_edit(slug, definition_id, row_id):
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
row = db.get_row(slug, definition, row_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
return _data_form(slug, definition_id, row=row)
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _data_form(slug, definition_id, row):
|
||||
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
|
||||
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
|
||||
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
|
||||
(data_form.html, retirée) — cette fonction ne fait donc plus que
|
||||
traiter la soumission de ce formulaire. Un GET direct redirige vers le
|
||||
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
|
||||
Données."""
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
|
||||
if request.method == "POST":
|
||||
if row is None:
|
||||
db.insert_row(slug, definition, request.form)
|
||||
else:
|
||||
db.update_row(slug, definition, row["id"], request.form)
|
||||
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
||||
@@ -1,7 +0,0 @@
|
||||
from core.flask_app import app
|
||||
from .data_form import _data_form
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/new", methods=["GET", "POST"])
|
||||
def data_new(slug, definition_id):
|
||||
return _data_form(slug, definition_id, row=None)
|
||||
@@ -1,19 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/delete", methods=["POST"])
|
||||
def object_delete(slug, definition_id):
|
||||
referenced_by = db.definitions_referencing(slug, definition_id)
|
||||
if referenced_by:
|
||||
# Suppression bloquée : d'autres objets ont une relation vers
|
||||
# celui-ci. On revient à son panneau, qui affiche déjà cet
|
||||
# avertissement (referenced_by_definition, voir game_dashboard.py)
|
||||
# en permanence, pas seulement après une tentative — plutôt que de
|
||||
# casser ces relations silencieusement.
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
db.delete_definition(slug, definition_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
|
||||
@@ -1,24 +0,0 @@
|
||||
from flask import request, redirect, url_for, abort
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
|
||||
def object_edit(slug, definition_id):
|
||||
"""Renommer un objet + gérer ses champs se fait désormais depuis un
|
||||
panneau déplaçable/redimensionnable sur le tableau de bord (voir
|
||||
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
|
||||
qu'une page dédiée — cette route ne fait donc plus que traiter le
|
||||
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
|
||||
le panneau de cet objet (?edit=<id>)."""
|
||||
if request.method == "POST":
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
if not definition:
|
||||
abort(404)
|
||||
new_name = request.form.get("object_name", "").strip()
|
||||
if new_name and new_name != definition["name"]:
|
||||
db.rename_definition(slug, definition_id, new_name)
|
||||
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,26 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/add", methods=["POST"])
|
||||
def object_field_add(slug, definition_id):
|
||||
name = request.form.get("field_name", "").strip()
|
||||
field_type = request.form.get("field_type", "texte")
|
||||
relation_definition_id = request.form.get("field_relation")
|
||||
# Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet
|
||||
# cible valide ferait planter add_field_to_definition (int("") lève),
|
||||
# ce qui annulait l'ALTER TABLE avant son commit() -- le champ
|
||||
# n'était alors ajouté du tout, sans le moindre message d'erreur.
|
||||
if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()):
|
||||
db.add_field_to_definition(slug, definition_id, {
|
||||
"name": name,
|
||||
"type": field_type,
|
||||
"relation_definition_id": relation_definition_id,
|
||||
"required": request.form.get("field_required") == "1",
|
||||
"min_value": request.form.get("field_min") or None,
|
||||
"max_value": request.form.get("field_max") or None,
|
||||
})
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,11 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
|
||||
def object_field_delete(slug, definition_id, field_id):
|
||||
db.delete_field(slug, definition_id, field_id)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,20 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/edit", methods=["POST"])
|
||||
def object_field_edit(slug, definition_id, field_id):
|
||||
new_name = request.form.get("field_name", "").strip()
|
||||
if new_name:
|
||||
db.update_field(
|
||||
slug, definition_id, field_id,
|
||||
new_name=new_name,
|
||||
required=request.form.get("field_required") == "1",
|
||||
relation_definition_id=request.form.get("field_relation"),
|
||||
min_value=request.form.get("field_min") or None,
|
||||
max_value=request.form.get("field_max") or None,
|
||||
)
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,35 +0,0 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
from .parse_field_rows import _parse_field_rows
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/new", methods=["GET", "POST"])
|
||||
def object_new(slug):
|
||||
"""La création d'un objet se fait désormais depuis un panneau déplaçable
|
||||
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
|
||||
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
|
||||
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
|
||||
externe obsolète) redirige simplement vers le tableau de bord."""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("object_name", "").strip()
|
||||
# Aucun champ requis à la création : create_definition(fields=[])
|
||||
# crée simplement une table sans colonne "métier" (juste id/
|
||||
# created_at), et le panneau "Modifier un objet" (onglet Champs,
|
||||
# bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins
|
||||
# un champ ici faisait échouer silencieusement la création (aucun
|
||||
# message d'erreur, juste un retour au tableau de bord) dès qu'on
|
||||
# tapait le nom sans avoir encore cliqué "+ Ajouter un champ".
|
||||
fields = _parse_field_rows(request.form)
|
||||
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
|
||||
# explicitement rend l'objet partagé par tous les joueurs — ex. un
|
||||
# catalogue/une liste de questions — voir db/definitions/
|
||||
# create_definition.py).
|
||||
per_player = request.form.get("per_player", "1") == "1"
|
||||
if name:
|
||||
definition_id = db.create_definition(slug, name, fields, per_player=per_player)
|
||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
@@ -1,18 +0,0 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/objects/<int:definition_id>")
|
||||
def object_view(slug, definition_id):
|
||||
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
|
||||
retirée : les données vivent désormais dans l'onglet "Données" du
|
||||
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
|
||||
route reste néanmoins en place, comme pur redirecteur, pour deux
|
||||
raisons : un lien externe/ancien vers cette URL continue de mener
|
||||
quelque part de sensé, et surtout beaucoup de tests (et certains
|
||||
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
|
||||
segment du chemin renvoyé par le header Location d'une redirection
|
||||
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
|
||||
termine par l'id), qui redirige ensuite vers le tableau de bord."""
|
||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
||||
@@ -1,36 +0,0 @@
|
||||
def _parse_field_rows(form):
|
||||
field_names = form.getlist("field_name[]")
|
||||
field_types = form.getlist("field_type[]")
|
||||
field_relations = form.getlist("field_relation[]")
|
||||
field_required = form.getlist("field_required[]")
|
||||
field_min = form.getlist("field_min[]")
|
||||
field_max = form.getlist("field_max[]")
|
||||
fields = []
|
||||
for i, fname in enumerate(field_names):
|
||||
if not fname.strip():
|
||||
continue
|
||||
ftype = field_types[i]
|
||||
relation_definition_id = field_relations[i] if i < len(field_relations) else None
|
||||
if ftype == "relation" and not (relation_definition_id or "").strip().isdigit():
|
||||
# Type "Relation vers un autre objet" choisi sans (ou avant
|
||||
# d'avoir pu) sélectionner d'objet cible réel — ex. le tout
|
||||
# premier objet créé dans un jeu, quand ce sélecteur est encore
|
||||
# vide faute d'un autre objet à pointer. create_definition()
|
||||
# fait un int(relation_definition_id) sans filet : le laisser
|
||||
# passer plantait TOUTE la requête (500), et comme la ligne
|
||||
# _definitions n'était alors jamais validée (pas de commit()
|
||||
# atteint), l'objet n'était pas créé du tout — pas de champ
|
||||
# "Relation" non plus, mais un panneau qui semble "ne rien
|
||||
# faire" plutôt qu'un message d'erreur clair. On ignore donc ce
|
||||
# champ précis (comme une ligne sans nom, ci-dessus) plutôt que
|
||||
# de faire échouer la création de TOUT l'objet à cause de lui.
|
||||
continue
|
||||
fields.append({
|
||||
"name": fname.strip(),
|
||||
"type": ftype,
|
||||
"relation_definition_id": relation_definition_id,
|
||||
"required": field_required[i] == "1" if i < len(field_required) else False,
|
||||
"min_value": (field_min[i].strip() or None) if i < len(field_min) else None,
|
||||
"max_value": (field_max[i].strip() or None) if i < len(field_max) else None,
|
||||
})
|
||||
return fields
|
||||
@@ -9,4 +9,6 @@ from . import (
|
||||
scene_object_collision_rules,
|
||||
scene_object_name,
|
||||
scene_object_dialogue_box_style,
|
||||
scene_collision_rule_objects,
|
||||
scene_object_names_list,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import screens
|
||||
|
||||
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
|
||||
|
||||
|
||||
def build_collision_rule_objects(objects):
|
||||
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
|
||||
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
|
||||
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||
screens/rendering/collision_rules.py) — factorisé ici plutôt que
|
||||
dupliqué : utilisé au premier rendu de la page (routes/scenes/
|
||||
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
|
||||
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
|
||||
scene-editor.js) pour reconstruire la liste SANS recharger la page.
|
||||
|
||||
`objects` doit déjà porter "rendered_html" (voir
|
||||
screens.render_scene_object) sur chaque élément.
|
||||
|
||||
resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans
|
||||
rôle explicitement enregistré (voir screens/rendering/
|
||||
personnage_role.py — comportement voulu pour une scène à un seul
|
||||
personnage, posé avant l'ajout de ce réglage). Appliqué tel quel,
|
||||
ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite
|
||||
(le cas de toute scène créée avant ce réglage — un personnage ajouté
|
||||
depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite,
|
||||
voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et
|
||||
disparaîtraient donc TOUS de cette liste. On ne considère donc comme
|
||||
"joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le
|
||||
plus ancien, z_index croissant comme `objects`) — les suivants sans
|
||||
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
|
||||
base."""
|
||||
implicit_joueur_claimed = any(
|
||||
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
|
||||
for o in objects if o["kind"] == "personnage"
|
||||
)
|
||||
result = []
|
||||
for o in objects:
|
||||
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
|
||||
continue
|
||||
role = None
|
||||
if o["kind"] == "personnage":
|
||||
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
|
||||
if explicit_role in screens.PERSONNAGE_ROLES:
|
||||
role = explicit_role
|
||||
elif not implicit_joueur_claimed:
|
||||
role = "joueur"
|
||||
implicit_joueur_claimed = True
|
||||
else:
|
||||
role = "pnj"
|
||||
if role == "joueur":
|
||||
continue
|
||||
result.append({
|
||||
"id": o["id"],
|
||||
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
|
||||
"kind": o["kind"],
|
||||
"role": role,
|
||||
"thumbnail_html": o["rendered_html"],
|
||||
"rules": screens.resolve_collision_rules(o),
|
||||
})
|
||||
return result
|
||||
@@ -0,0 +1,24 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
from .collision_rule_objects import build_collision_rule_objects
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/collision-rule-objects", methods=["GET"])
|
||||
def scene_collision_rule_objects(slug, screen_id):
|
||||
"""Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS,
|
||||
templates/scene_edit.html) — appelée en AJAX après un ajout/suppression
|
||||
d'objet, ou un changement de rôle/nom (voir static/js/scenes/
|
||||
scene-editor.js) qui peut faire apparaître/disparaître un objet de
|
||||
cette liste (rôle passé à/depuis "joueur", voir
|
||||
build_collision_rule_objects) ou changer son libellé affiché (nom).
|
||||
Recalculée entièrement plutôt que diffée : la liste reste petite (un
|
||||
objet de scène par carte, jamais un "fond"/widget d'UI) et cette même
|
||||
fonction est déjà appelée à chaque rendu complet de la page."""
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
return jsonify(build_collision_rule_objects(objects))
|
||||
@@ -5,6 +5,8 @@ from flask import render_template, request, abort, g
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .collision_rule_objects import build_collision_rule_objects
|
||||
|
||||
|
||||
def render_scene_edit(slug, screen_id, game):
|
||||
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
||||
@@ -22,22 +24,17 @@ def render_scene_edit(slug, screen_id, game):
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
|
||||
# "Monde" (voir static/js/play/personnage-controller.js::
|
||||
# forgeUpdateSceneWorldSize, même principe côté serveur) : un "fond"
|
||||
# posé à sa taille RÉELLE (souvent plus grande que la scène, voir
|
||||
# add_scene_object.py) rend visible/joignable, dans CET éditeur, tout
|
||||
# ce qui dépasse la scène nominale plutôt que de le rogner (le
|
||||
# "Monde" (voir screens.resolve_scene_world_size, réutilisé aussi par
|
||||
# la miniature de "Mes écrans", routes/games/game_dashboard.py) : un
|
||||
# "fond" posé à sa taille RÉELLE (souvent plus grande que la scène,
|
||||
# voir add_scene_object.py) rend visible/joignable, dans CET éditeur,
|
||||
# tout ce qui dépasse la scène nominale plutôt que de le rogner (le
|
||||
# glisser-déposer le permet déjà depuis le fix précédent — sans
|
||||
# agrandir le canevas ici, cette partie restait invisible/injoignable
|
||||
# à la souris). Le rectangle "🎥 Champ de la caméra" (voir
|
||||
# templates/scene_edit.html) marque ensuite où s'arrête la scène
|
||||
# nominale À L'INTÉRIEUR de ce monde plus grand.
|
||||
world_width, world_height = screen["scene_width"], screen["scene_height"]
|
||||
for o in objects:
|
||||
if o["kind"] == "fond":
|
||||
world_width = max(world_width, o["x"] + o["width"])
|
||||
world_height = max(world_height, o["y"] + o["height"])
|
||||
world_width, world_height = int(world_width), int(world_height)
|
||||
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
||||
|
||||
selected_id = request.args.get("selected", type=int)
|
||||
personnage_data = None
|
||||
@@ -66,28 +63,11 @@ def render_scene_edit(slug, screen_id, game):
|
||||
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
|
||||
}
|
||||
|
||||
# "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet
|
||||
# ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
|
||||
# TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||
# screens/rendering/collision_rules.py) — construit ici plutôt que
|
||||
# réutiliser `objects` tel quel, pour ne poser resolve_personnage_role
|
||||
# qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet
|
||||
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
|
||||
collision_rule_objects = []
|
||||
for o in objects:
|
||||
if o["kind"] in ("fond", "dialogue_box", "quiz_box", "score_widget"):
|
||||
continue
|
||||
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
|
||||
if role == "joueur":
|
||||
continue
|
||||
collision_rule_objects.append({
|
||||
"id": o["id"],
|
||||
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
|
||||
"kind": o["kind"],
|
||||
"role": role,
|
||||
"thumbnail_html": o["rendered_html"],
|
||||
"rules": screens.resolve_collision_rules(o),
|
||||
})
|
||||
# "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/
|
||||
# collision_rule_objects.py, factorisé pour être réutilisé par
|
||||
# l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/
|
||||
# suppression/changement de rôle ou de nom sans recharger la page).
|
||||
collision_rule_objects = build_collision_rule_objects(objects)
|
||||
|
||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
@@ -10,7 +10,20 @@ from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_back
|
||||
def scene_object_add(slug, screen_id):
|
||||
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
|
||||
element_add.py, mais pour _scene_objects (voir screens/scenes/
|
||||
add_scene_object.py)."""
|
||||
add_scene_object.py).
|
||||
|
||||
Appelée en AJAX (voir addSceneObject(), static/js/scenes/scene-editor.js)
|
||||
plutôt qu'en soumission de formulaire classique — sans ça, poser un
|
||||
objet rechargeait TOUTE la page : perceptible comme un "vidage" de
|
||||
l'éditeur de collision/quêtes le temps du rechargement, et perdait tout
|
||||
état JS en cours (modale ouverte, etc.). Répond en JSON avec de quoi
|
||||
ajouter l'objet au canevas SANS recharger — jamais sélectionné/ouvert
|
||||
dans le panneau de propriétés pour autant (ça, voir
|
||||
onSceneObjectMouseDown, reste un rechargement pour l'instant). La liste
|
||||
"🧩 Collision" n'est PAS incluse ici : addSceneObject() la redemande
|
||||
ensuite à routes/scenes/scene_collision_rule_objects.py (même endpoint
|
||||
que pour un changement de rôle/nom, ou une suppression) plutôt que de
|
||||
dupliquer ici la logique de build_collision_rule_objects()."""
|
||||
kind = request.form.get("kind", "personnage")
|
||||
forge_character = request.form.get("forge_character")
|
||||
background_slug = request.form.get("background_slug")
|
||||
@@ -21,7 +34,22 @@ def scene_object_add(slug, screen_id):
|
||||
new_id = screens.add_scene_object(
|
||||
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
|
||||
)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
obj = screens.get_scene_object(slug, new_id)
|
||||
return jsonify({
|
||||
"id": new_id, "kind": obj["kind"],
|
||||
"x": obj["x"], "y": obj["y"], "width": obj["width"], "height": obj["height"],
|
||||
"rendered_html": screens.render_scene_object(obj),
|
||||
# Relance l'aperçu animé tout de suite (voir personnage-preview.js
|
||||
# ::restartPersonnageIdlePreview) — sans ça, un personnage
|
||||
# fraîchement posé restait figé sur sa première frame jusqu'au
|
||||
# prochain rechargement de page (qui n'arrive plus jamais, voir
|
||||
# addSceneObject() côté JS).
|
||||
"animations": screens.resolve_personnage_animations(obj) if obj["kind"] == "personnage" else None,
|
||||
})
|
||||
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
|
||||
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
||||
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
|
||||
# Conservé pour tout appelant non-JS (aucun connu actuellement, mais
|
||||
# ne coûte rien à garder en secours).
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import redirect, url_for
|
||||
from flask import redirect, request, url_for, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
@@ -7,9 +7,24 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
|
||||
def scene_object_delete(slug, object_id):
|
||||
"""Supprime un objet de scène — bouton 🗑️ du panneau de propriétés
|
||||
(voir templates/scene_edit.html), toujours pour l'objet SÉLECTIONNÉ (le
|
||||
seul dont ce bouton est affiché).
|
||||
|
||||
Appelée en AJAX (deleteSceneObject(), static/js/scenes/scene-editor.js)
|
||||
plutôt qu'en soumission de formulaire classique — sinon même
|
||||
"rechargé en douceur" par pjax.js (qui intercepte tout <form>, voir son
|
||||
commentaire d'en-tête), ça remplaçait TOUT <main> (canevas + onglets
|
||||
Collision/Quêtes/Événements), perceptible comme un "vidage" de
|
||||
l'éditeur le temps du remplacement."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if not obj:
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": False}), 404
|
||||
return redirect(url_for("index"))
|
||||
screen_id = obj["scene_id"]
|
||||
screens.delete_scene_object(slug, object_id)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": True, "id": object_id})
|
||||
# Secours pour tout appelant non-JS (aucun connu actuellement).
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from flask import jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scene-object-names", methods=["GET"])
|
||||
def scene_object_names_list(slug):
|
||||
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
|
||||
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
|
||||
templates/scene_edit.html, questSpeakerOptions() dans static/js/
|
||||
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
|
||||
chargement complet de la page (HORS de #builder3, jamais rafraîchie
|
||||
par une sélection d'objet sans rechargement, voir goToSelected()) :
|
||||
sans cet endpoint, renommer un personnage puis rouvrir la modale de
|
||||
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
|
||||
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
|
||||
temps — appelée en AJAX depuis saveSceneObjectName()."""
|
||||
return jsonify(screens.list_named_scene_object_names(slug))
|
||||
@@ -1 +1 @@
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music, screen_set_scene_size
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_background_music, screen_set_scene_size
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import json
|
||||
|
||||
from flask import render_template, request, abort, g
|
||||
from flask import abort
|
||||
|
||||
import db
|
||||
import screens
|
||||
@@ -15,124 +13,6 @@ def screen_edit(slug, screen_id):
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if not screen:
|
||||
abort(404)
|
||||
if screen["kind"] == "jeu_2d":
|
||||
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
|
||||
# le type d'écran ("document" ou "jeu_2d") est une propriété de
|
||||
# L'ÉCRAN, pas du projet — même route/endpoint pour les deux,
|
||||
# dispatch ici selon l'écran demandé — tout le reste de cette
|
||||
# fonction ne concerne QUE l'éditeur document (comportement
|
||||
# inchangé pour un écran "document").
|
||||
return render_scene_edit(slug, screen_id, game)
|
||||
elements = screens.list_elements(slug, screen_id)
|
||||
all_screens = screens.list_screens(slug)
|
||||
element_types = screens.list_element_types(slug)
|
||||
selected_id = request.args.get("selected", type=int)
|
||||
selected_controls = None
|
||||
personnage_data = None
|
||||
if selected_id:
|
||||
selected_el = next((e for e in elements if e["id"] == selected_id), None)
|
||||
if selected_el:
|
||||
template_definition_id = None
|
||||
if screen.get("is_template"):
|
||||
owning_type = screens.get_element_type_by_template_screen(slug, screen_id)
|
||||
if owning_type:
|
||||
template_definition_id = owning_type.get("bound_definition_id")
|
||||
selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id)
|
||||
if selected_el.get("widget") == "personnage":
|
||||
# Phase 8 — panneau de propriétés sur-mesure (custom_panel,
|
||||
# voir registry.py) : pas de controls génériques à afficher,
|
||||
# juste la source (Forge/personnalisée) et son contenu.
|
||||
personnage_data = screens.resolve_personnage_data(selected_el)
|
||||
children_of = {}
|
||||
for el in elements:
|
||||
if el.get("parent_id"):
|
||||
children_of.setdefault(el["parent_id"], []).append(el)
|
||||
top_level_elements = [el for el in elements if not el.get("parent_id")]
|
||||
elements_by_id = {el["id"]: el for el in elements}
|
||||
|
||||
definitions = db.list_definitions(slug)
|
||||
definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
|
||||
# Phase 4 — nodeLabel() (static/js/screen_edit/flow-editor.js) affiche
|
||||
# le nom de l'objet visé par un nœud "Ajouter une ligne" ; DEFINITIONS_DATA
|
||||
# (ci-dessus) ne porte que lignes/champs, jamais le nom lui-même.
|
||||
definitions_names = {d["id"]: d["name"] for d in definitions}
|
||||
|
||||
# list_flow_blocks() rattache paresseusement les nœuds "orphelins"
|
||||
# (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" —
|
||||
# DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes
|
||||
# garderait en mémoire un block_id périmé (None) pour ces nœuds.
|
||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||
flow_edges = screens.list_flow_edges(slug, screen_id)
|
||||
elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements}
|
||||
screens_map = {s["id"]: s["name"] for s in all_screens}
|
||||
# Phase 8 — sélecteur d'animation à miniatures (nœud de flow "Jouer une
|
||||
# animation" / clip de Timeline "sprite") : scopé aux SEULES animations
|
||||
# de l'élément personnage réellement ciblé, jamais un catalogue global.
|
||||
element_animations_map = {
|
||||
el["id"]: screens.resolve_personnage_animations(el) for el in elements if el.get("widget") == "personnage"
|
||||
}
|
||||
animation_clips = screens.list_animation_clips(slug, screen_id)
|
||||
global_variables = db.list_global_variables(slug)
|
||||
custom_events = db.list_custom_events(slug)
|
||||
custom_event_usages = screens.list_custom_event_usages(slug)
|
||||
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
|
||||
|
||||
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels)
|
||||
|
||||
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
|
||||
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
|
||||
# "Bibliothèque de sprites animaux CraftPix") — un compte "user" ne
|
||||
# doit même pas les voir dans la galerie.
|
||||
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
|
||||
sprite_library_families = screens.sprite_gallery_families(sprite_library)
|
||||
|
||||
return render_template(
|
||||
"screen_edit.html",
|
||||
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
|
||||
children_of=children_of, container_widgets=screens.CONTAINER_WIDGETS,
|
||||
elements_by_id=elements_by_id,
|
||||
all_screens=all_screens,
|
||||
element_types=element_types, widgets=screens.WIDGETS,
|
||||
trigger_labels=screens.TRIGGER_LABELS, action_type_labels=screens.ACTION_TYPE_LABELS,
|
||||
element_action_properties=screens.ELEMENT_ACTION_PROPERTIES,
|
||||
element_action_property_labels=screens.ELEMENT_ACTION_PROPERTY_LABELS,
|
||||
element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES,
|
||||
element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS,
|
||||
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
|
||||
element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES,
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
personnage_data=personnage_data,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
data_operations=screens.DATA_OPERATIONS,
|
||||
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
||||
score_status_choices=db.SCORE_STATUS_CHOICES,
|
||||
score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS),
|
||||
definitions=definitions, definitions_json=json.dumps(definitions_data),
|
||||
selected_id=selected_id, selected_controls=selected_controls,
|
||||
flow_nodes=flow_nodes, flow_edges=flow_edges,
|
||||
flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges),
|
||||
trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS,
|
||||
elements_labels_json=json.dumps(elements_labels), screens_map_json=json.dumps(screens_map),
|
||||
action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS),
|
||||
element_action_property_labels_json=json.dumps(screens.ELEMENT_ACTION_PROPERTY_LABELS),
|
||||
element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS),
|
||||
element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS),
|
||||
element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_LABELS),
|
||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
|
||||
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
||||
trigger_labels_json=json.dumps(screens.TRIGGER_LABELS),
|
||||
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
|
||||
animate_css_catalog=screens.ANIMATE_CSS_CATALOG,
|
||||
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
|
||||
fontawesome_icons=screens.FONTAWESOME_ICONS,
|
||||
global_variables=global_variables,
|
||||
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
||||
custom_events_map_json=json.dumps(custom_events_map),
|
||||
blocks_view=blocks_view,
|
||||
flow_blocks_json=json.dumps(flow_blocks),
|
||||
definitions_names_json=json.dumps(definitions_names),
|
||||
)
|
||||
# Un seul moteur d'écran désormais (jeu_2d) — voir
|
||||
# screens/screens_repo/ensure_schema.py.
|
||||
return render_scene_edit(slug, screen_id, game)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/aspect", methods=["POST"])
|
||||
def screen_set_aspect(slug, screen_id):
|
||||
"""Mémorise le format d'aperçu (Portrait/Paysage/Carré) choisi pour cet
|
||||
écran, en AJAX — sans recharger la page, pour ne jamais perdre ce choix
|
||||
au prochain ajout/suppression d'élément ou changement de sélection (qui
|
||||
redemandent le panneau au serveur)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
aspect = body.get("aspect", "")
|
||||
screens.set_screen_aspect(slug, screen_id, aspect)
|
||||
return jsonify({"ok": True})
|
||||
@@ -9,18 +9,9 @@ from core.flask_app import app
|
||||
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
||||
def screens_new(slug):
|
||||
name = request.form.get("name", "").strip()
|
||||
if db.is_restricted(slug):
|
||||
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
|
||||
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
|
||||
# imposé par le projet (jeu_2d pour un RPG, document pour les
|
||||
# autres), même valeur pour TOUS ses écrans (voir
|
||||
# db/games/game_type_catalog.py).
|
||||
onboarding_type = db.get_onboarding_type(slug)
|
||||
kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
|
||||
else:
|
||||
kind = request.form.get("kind", "document")
|
||||
if kind not in ("document", "jeu_2d"):
|
||||
kind = "document"
|
||||
# Un seul moteur d'écran désormais (jeu_2d) — imposé à tout écran créé,
|
||||
# quel que soit le projet (voir db/games/game_type_catalog.py).
|
||||
kind = "jeu_2d"
|
||||
if name:
|
||||
screen_id = screens.create_screen(slug, name, kind=kind)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||
|
||||
Reference in New Issue
Block a user