Merge pull request 'Dev' (#11) from dev into main
Build and deploy / test-python (push) Successful in 7m0s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / build-and-push (push) Successful in 5m40s
Build and deploy / deploy (push) Successful in 55s

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-09-04 19:13:40 +00:00
246 changed files with 3805 additions and 12349 deletions
+4
View File
@@ -37,3 +37,7 @@ assets/background/
Thumbs.db
.vscode/
.idea/
# Documents internes/business (cadrage produit...) et état local de
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
.claude/
+9 -1
View File
@@ -33,4 +33,12 @@ def _open_browser():
if __name__ == "__main__":
threading.Timer(1.0, _open_browser).start()
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False)
# threaded=True : le serveur de dev Werkzeug traite UNE requête à la
# fois par défaut — un jeu 2D charge des dizaines d'images (sprites de
# personnages, fonds...) en parallèle à chaque chargement de page, et
# sans ce réglage elles se mettaient en file d'attente une par une
# (bug remonté : un Ctrl+F5, qui recharge TOUT sans cache, laissait
# l'éditeur de scène "vide" le temps que les images finissent par
# arriver l'une après l'autre — un rechargement normal, servi surtout
# depuis le cache navigateur, le cachait).
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True)
-6
View File
@@ -1,12 +1,6 @@
import screens
from core.flask_app import app
from filters.colname_filter import _colname
from filters.element_style_filter import _element_style, _element_transform_style
from filters.truncate_sentences_filter import _truncate_sentences
app.jinja_env.filters["colname"] = _colname
app.jinja_env.filters["elstyle"] = _element_style
app.jinja_env.filters["eltransform"] = _element_transform_style
app.jinja_env.filters["elabel"] = screens.element_label
app.jinja_env.filters["truncatesentences"] = _truncate_sentences
+5 -4
View File
@@ -79,7 +79,7 @@ from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
from .quests.constants import (
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES,
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS,
)
from .quests.list_quests import list_quests
from .quests.get_quest import get_quest
@@ -87,7 +87,8 @@ from .quests.create_quest import create_quest
from .quests.update_quest import update_quest
from .quests.delete_quest import delete_quest
from .quests.set_quest_dialogues import set_quest_dialogues
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES
from .quests.reward_budget import quest_dialogues_total_reward
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
@@ -106,7 +107,7 @@ __all__ = [
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
"set_quest_dialogues", "sanitize_quest_dialogues",
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
]
+31 -34
View File
@@ -15,48 +15,45 @@ rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py)."""
import db
DEFAULT_ONBOARDING_TYPE = "custom"
DEFAULT_ONBOARDING_TYPE = "rpg"
ONBOARDING_TYPES = {
"quiz": {
"label": "Quiz",
"tagline": "Questions, réponses, score.",
"icon": "🎯",
"description": "Gérer des questions/réponses, avec score.",
"screen_kind": "document",
"restricted": True,
},
"embranchement": {
"label": "Jeu à embranchement / escape game",
"tagline": "Chaque choix a une conséquence.",
"icon": "🗝️",
"description": (
"Former à travers le jeu sur des concepts métier avec des "
"conséquences observables ; réaliser des escape games sous "
"forme d'énigmes."
),
"screen_kind": "document",
"restricted": True,
},
"rpg": {
"label": "Jeu RPG",
"label": "Créer un jeu 2D ludique",
"tagline": "Ton monde, ton héros, ton scénario.",
"icon": "🗺️",
"description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.",
# Argumentaire pour un public RH non technique (voir .claude/
# Forge_Engine_Cadrage.pdf) : chaque point correspond à une
# fonctionnalité RÉELLEMENT construite (assistant "+ Action" de
# l'éditeur de collision pour les interactions, "❓ Boîte à quiz"/
# "🏆 Score" posables comme n'importe quel objet de scène, dialogue
# de quête par état "Nouvelle/En cours/Terminée", export SCORM 1.2
# natif), jamais une promesse. Du HTML de confiance (jamais de
# saisie utilisateur ici) — rendu via `|safe` dans
# templates/onboarding/onboarding_new.html, stylé par
# .onboardingPitch* dans static/style.css : liste à coche + badge
# LMS, pensé pour convaincre en un coup d'œil plutôt qu'un pavé de
# texte brut.
"description": (
'<span class="onboardingPitchTitle">Un jeu 2D ludique,'
" sans une ligne de code</span>"
'<ul class="onboardingPitchList">'
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Personnages vivants</strong> — posez-les, donnez-leur"
" un rôle, ils bougent et réagissent seuls</span></li>"
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Quiz animés</strong> — intégrés à la scène,"
" correction immédiate</span></li>"
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Score &amp; dialogues</strong> — évoluent avec"
" chaque quête</span></li>"
"</ul>"
'<span class="onboardingPitchBadge">🎓 Compatible LMS —'
" export SCORM en 1 clic</span>"
),
"screen_kind": "jeu_2d",
"restricted": True,
},
"custom": {
"label": "Créer mon jeu de A à Z",
"tagline": "Toute la puissance du moteur, sans limite.",
"icon": "⚡",
"description": (
"Pour les utilisateurs avec des notions de dev/jeu vidéo : "
"accès à toute la puissance du moteur."
),
"screen_kind": "document",
"restricted": False,
},
}
+1 -1
View File
@@ -1,6 +1,6 @@
from ..connection import connect
DEFAULT_GAME_TYPE = "document"
DEFAULT_GAME_TYPE = "jeu_2d"
def get_game_type(slug):
-1
View File
@@ -2,4 +2,3 @@ QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
QUEST_SPEAKER_CHOICES = ["joueur", "pnj"]
+15
View File
@@ -0,0 +1,15 @@
def quest_dialogues_total_reward(dialogues):
"""Somme des récompenses de toutes les questions ("❓ Question", voir
sanitize_quest_dialogues.py), TOUTES colonnes/statuts confondus — une
quête n'a qu'UNE SEULE récompense totale (quest["recompense_score"]),
jamais une par statut. Utilisée pour empêcher d'enregistrer plus de
points répartis entre les questions que la quête n'en offre (voir
routes/quests/quest_dialogues.py et quest_update.py)."""
total = 0
for lines in (dialogues or {}).values():
if not isinstance(lines, list):
continue
for line in lines:
if isinstance(line, dict) and line.get("type") == "question":
total += line.get("reward_amount", 0) or 0
return total
+69 -17
View File
@@ -1,13 +1,75 @@
from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES
from .constants import QUEST_STATUS_CHOICES
_MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60
# Question à choix (voir "❓ Question", static/js/quests/quest-editor.js) :
# une bulle jaune posée dans le même enchaînement qu'une réplique de
# dialogue — plusieurs choix, une seule bonne réponse, une récompense.
# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/
# db.set_score_value) — liste plutôt qu'un booléen pour pouvoir en ajouter
# d'autres plus tard sans changer la forme des données.
QUESTION_REWARD_TYPES = ["score"]
_MIN_CHOICES = 2
_MAX_CHOICES = 4
def _sanitize_dialogue_line(raw_line):
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if not isinstance(speaker, str) or not speaker.strip():
return None
if not isinstance(text, str) or not text.strip():
return None
return {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}
def _sanitize_question_line(raw_line):
text = raw_line.get("text")
if not isinstance(text, str) or not text.strip():
return None
raw_choices = raw_line.get("choices")
if not isinstance(raw_choices, list):
return None
choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
if len(choices) < _MIN_CHOICES:
return None
try:
correct_index = int(raw_line.get("correct_index"))
except (TypeError, ValueError):
return None
if not (0 <= correct_index < len(choices)):
return None
reward_type = raw_line.get("reward_type")
if reward_type not in QUESTION_REWARD_TYPES:
reward_type = QUESTION_REWARD_TYPES[0]
try:
reward_amount = max(0, int(raw_line.get("reward_amount", 0)))
except (TypeError, ValueError):
reward_amount = 0
return {
"type": "question", "text": text.strip(), "choices": choices,
"correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount,
}
def _sanitize_line(raw_line):
if not isinstance(raw_line, dict):
return None
if raw_line.get("type") == "question":
return _sanitize_question_line(raw_line)
return _sanitize_dialogue_line(raw_line)
def sanitize_quest_dialogues(raw_dialogues):
"""Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne
par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que
resolve_collision_rules.sanitize_collision_rules : rejette tout
élément invalide plutôt que de lever, ne garde jamais un texte vide."""
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de
répliques de dialogue ({type: "dialogue", speaker, text} — le nom
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur",
voir screens/rendering/scene_object_names.py) ET/OU de questions à
choix ({type: "question", text, choices, correct_index, reward_type,
reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit
que resolve_collision_rules.sanitize_collision_rules : rejette tout
élément invalide plutôt que de lever."""
result = {status: [] for status in QUEST_STATUS_CHOICES}
if not isinstance(raw_dialogues, dict):
return result
@@ -15,16 +77,6 @@ def sanitize_quest_dialogues(raw_dialogues):
raw_lines = raw_dialogues.get(status)
if not isinstance(raw_lines, list):
continue
cleaned = []
for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]:
if not isinstance(raw_line, dict):
continue
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if speaker not in QUEST_SPEAKER_CHOICES:
continue
if not isinstance(text, str) or not text.strip():
continue
cleaned.append({"speaker": speaker, "text": text.strip()})
result[status] = cleaned
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
result[status] = [line for line in cleaned if line]
return result
-76
View File
@@ -1,76 +0,0 @@
def _element_style(el):
"""Construit le style inline de POSITIONNEMENT (position/taille en % +
empilement) du cadre qui porte un élément posé sur un écran. L'apparence
de l'élément lui-même (couleurs, police, etc.) vit dans le HTML généré
par screens.render_element_html — ce filtre ne gère que le placement,
partagé par l'éditeur et le mode jouable.
Ce cadre (.canvasElement / .playElement) est aussi la cible des
animations Animate.css (data-anim-target, voir screen_edit.html et
play.html) : il ne porte donc PAS le "transform" (échelle/rotation) de
l'élément, pour ne jamais entrer en collision avec le "transform" que
Animate.css pilote lui-même pendant qu'une animation joue (Animate.css
pose "animation-fill-mode: both", qui remplace entièrement la valeur de
la propriété — un "transform" inline posé ici serait écrasé pendant, et
pour une animation en boucle infinie, en permanence). Voir
_element_transform_style, qui porte ce "transform" sur un cadre séparé
(.canvasElementInner / .playElementInner), à la taille exacte de celui-ci
(donc visuellement identique).
Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà
x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999
sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même
"z-index:{z_index}" (le z-index de sa place dans le canevas, souvent
petit), un élément positionné avec un z-index EXPLICITE (même sur ce
cadre, pas sur son contenu) devient un NOUVEAU contexte d'empilement
CSS — le z-index:9999 posé plus profond ne se comparait alors plus
qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un
frère ajouté après lui sur le canevas, qui se retrouvait affiché
PAR-DESSUS le dialogue censé tout couvrir.
Première correction tentée (retirer aussi position/left/top/width/
height ici) : régression dans l'ÉDITEUR — ce même cadre (.canvasElement)
sert aussi de prise pour glisser-déposer/redimensionner CE widget dans
le canevas (voir screen_edit.html), dont le calcul se base sur ses
dimensions réelles ; sans position:absolute + left/top/width/height, ce
cadre s'effondre à 0×0 (son contenu réel est en position:fixed, donc
hors flux), le calcul division par zéro produit NaN, et
JSON.stringify(NaN) donne "null" — element_geometry.py plantait alors
sur float(None). left/top/width/height restent donc posés comme pour
n'importe quel widget (l'éditeur continue de fonctionner normalement) :
SEUL le z-index est omis pour ce widget précis. position:absolute SANS
z-index explicite (donc z-index:auto) ne crée PAS de nouveau contexte
d'empilement — le z-index:9999 posé plus profond se compare alors
directement aux autres éléments de l'écran, et gagne toujours.
Même omission pour un exemplaire (element_type_id) d'un élément de jeu
dont le modèle N'EST QU'une superposition (marqué "_overlay_only_instance"
par list_elements.py) : son propre widget est "conteneur" par défaut
(add_element.py), pas "superposition", mais son cadre entoure quand
même une superposition rendue à l'intérieur (voir
render_element_html.py/_render_element_type_children) — exactement le
même risque de contexte d'empilement piégeant."""
exempt_from_z_index = (
el.get("widget") == "superposition" or el.get("_overlay_only_instance")
)
parts = [
"position:absolute",
f"left:{el['x']}%", f"top:{el['y']}%",
f"width:{el['width']}%", f"height:{el['height']}%",
]
if not exempt_from_z_index:
parts.append(f"z-index:{el['z_index']}")
return "; ".join(parts) + ";"
def _element_transform_style(el):
"""Style inline de l'échelle/rotation d'un élément posé directement sur
un écran — voir _element_style pour pourquoi ce "transform" est séparé
du cadre de positionnement/animation. Ce cadre intermédiaire
(.canvasElementInner / .playElementInner) fait exactement la même taille
que le cadre extérieur (width/height:100%, voir static/style.css et
play.html) : y poser le "transform" produit donc le même rendu visuel
que s'il était sur le cadre extérieur, sans le risque de collision avec
Animate.css."""
transform = (el.get("style") or {}).get("transform")
return f"transform:{transform};" if transform else ""
+1
View File
@@ -252,6 +252,7 @@ def build_scorm_zip(slug):
html = render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename,
scorm_api_wrapper_url="static/js/play/offline/scorm-api.js",
)
html = _relativize_absolute_urls(html, slug)
html = _inline_icon_urls(html, payload["icon_data_uris"])
+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
+21 -4
View File
@@ -9,9 +9,26 @@ from core.flask_app import app
def quest_dialogues(slug, quest_id):
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
complet à chaque modification plutôt qu'une réplique isolée."""
body = request.get_json(silent=True) or {}
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
if dialogues is None:
complet à chaque modification plutôt qu'une réplique isolée.
Refusé (400) si la somme des récompenses des questions ("❓ Question")
dépasse la récompense totale de la quête (recompense_score) — voir
db.quest_dialogues_total_reward. Validé ICI (avant persistance)
plutôt que dans db.set_quest_dialogues, pour renvoyer un message
d'erreur explicite au lieu d'enregistrer silencieusement un état
incohérent."""
quest = db.get_quest(slug, quest_id)
if quest is None:
return jsonify({"error": "quête introuvable"}), 404
body = request.get_json(silent=True) or {}
sanitized = db.sanitize_quest_dialogues(body.get("dialogues"))
allocated = db.quest_dialogues_total_reward(sanitized)
if allocated > quest["recompense_score"]:
return jsonify({
"error": (
f"La somme des récompenses des questions ({allocated} pts) dépasse "
f"la récompense totale de la quête ({quest['recompense_score']} pts)."
),
}), 400
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
return jsonify({"dialogues": dialogues})
+22
View File
@@ -7,7 +7,29 @@ from core.flask_app import app
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
def quest_update(slug, quest_id):
"""Refusé (400) si "recompense_score" est abaissé SOUS le total déjà
réparti entre les questions ("❓ Question") du dialogue de cette
quête — voir db.quest_dialogues_total_reward, même contrainte que
routes/quests/quest_dialogues.py mais dans l'autre sens (ici c'est le
budget total qui bouge, pas la répartition)."""
body = request.get_json(silent=True) or {}
if "recompense_score" in body:
quest = db.get_quest(slug, quest_id)
if quest is None:
return jsonify({"error": "quête introuvable"}), 404
try:
new_score = int(body["recompense_score"])
except (TypeError, ValueError):
new_score = None
if new_score is not None:
allocated = db.quest_dialogues_total_reward(quest["dialogues"])
if new_score < allocated:
return jsonify({
"error": (
f"La récompense totale ({new_score} pts) est inférieure aux "
f"{allocated} pts déjà répartis entre les questions du dialogue."
),
}), 400
quest = db.update_quest(slug, quest_id, body)
if quest is None:
return jsonify({"error": "quête introuvable"}), 404
+4
View File
@@ -7,4 +7,8 @@ from . import (
scene_object_role,
scene_object_collision,
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))
+34 -23
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/
@@ -21,18 +23,36 @@ def render_scene_edit(slug, screen_id, game):
objects = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
# "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 = screens.resolve_scene_world_size(screen, objects)
selected_id = request.args.get("selected", type=int)
personnage_data = None
personnage_commands = None
personnage_role = None
collision_settings = None
dialogue_box_style = None
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
_UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget")
if selected_obj:
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] not in _UI_WIDGET_KINDS:
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(selected_obj)
personnage_role = screens.resolve_personnage_role(selected_obj)
elif selected_obj["kind"] in _UI_WIDGET_KINDS:
dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj)
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
object_labels = {
@@ -43,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"] == "fond":
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)
@@ -90,11 +93,14 @@ def render_scene_edit(slug, screen_id, game):
return render_template(
"scene_edit.html",
game=game, screen=screen, all_screens=all_screens,
world_width=world_width, world_height=world_height,
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
dialogue_box_style=dialogue_box_style or screens.DEFAULT_DIALOGUE_BOX_STYLE,
dialogue_box_font_weight_choices=screens.FONT_WEIGHT_CHOICES,
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
background_library=background_library,
@@ -134,4 +140,9 @@ def render_scene_edit(slug, screen_id, game):
# existante, sans attendre l'ouverture de l'assistant "+ Action"
# (seul moment où questPickerHtml irait la chercher sinon).
quests_json=json.dumps(db.list_quests(slug)),
# "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations",
# static/js/quests/quest-editor.js) : les noms donnés à N'IMPORTE
# QUEL objet de scène (personnage, décor, fond), toutes scènes du
# jeu confondues.
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
)
+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))
@@ -0,0 +1,14 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/dialogue-box-style", methods=["POST"])
def scene_object_dialogue_box_style(slug, object_id):
body = request.get_json(silent=True) or {}
style = screens.set_scene_object_dialogue_box_style(slug, object_id, body.get("style"))
if style is None:
return jsonify({"error": "objet introuvable"}), 404
return jsonify({"style": style})
+12
View File
@@ -0,0 +1,12 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/name", methods=["POST"])
def scene_object_name(slug, object_id):
data = request.get_json(force=True)
screens.set_scene_object_name(slug, object_id, data.get("name"))
return jsonify({"ok": True})
+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
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})
+20
View File
@@ -0,0 +1,20 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/screens/<int:screen_id>/scene-size", methods=["POST"])
def screen_set_scene_size(slug, screen_id):
""""📐 Agrandir la caméra à la zone visible" (voir templates/
scene_edit.html) — redimensionne la scène/caméra elle-même à la
taille du "monde" affiché (world_width/world_height, voir
routes/scenes/scene_edit_view.py), calculée et postée par le client."""
body = request.get_json(silent=True) or {}
try:
width, height = int(body.get("width")), int(body.get("height"))
except (TypeError, ValueError):
return jsonify({"error": "largeur/hauteur invalides"}), 400
screens.set_scene_size(slug, screen_id, width, height)
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))
+15 -67
View File
@@ -1,34 +1,15 @@
"""
screens.py — écrans de jeu : éléments posés en glisser-déposer sur un
écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans,
et actions déclenchées par clic ou soumission de formulaire.
Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de
WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de
données...), chacun avec ses propres réglages ("controls") : un réglage =
un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom
de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget
correspond à une vraie balise HTML avec de vrais attributs et un vrai
style CSS ; l'utilisateur ne les manipule jamais directement.
screens.py — écrans de jeu : un seul moteur d'écran, la scène 2D "jeu_2d"
(voir screens/scenes/) — objets posés en pixels, enchaînement des écrans,
et logique déclenchée par clic/collision/clavier/minuteur (voir
screens/flow/).
Tout est stocké dans la même base SQLite que le reste du jeu
(projects/<slug>/game.db) :
- _screens : un écran du jeu (accueil, formulaire, scène...)
- _element_types : le "catalogue" des éléments de jeu réutilisables
(Personnage, Outil, IA...) que l'utilisateur crée
- _screen_elements : chaque élément posé sur un écran (widget, position
et taille en POURCENTAGE de l'écran, contenu,
attributs et style — dérivés des réglages du
widget, jamais saisis en texte libre)
- _actions : ce qui se passe au clic / à la soumission d'un
élément (écran suivant, écran précédent, aller à
un écran précis, ne rien faire)
Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu
fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche
automatiquement une ligne par donnée existante — par exemple, tous les
niveaux d'un parcours — à partir d'un modèle de texte avec des
{{nom_de_champ}}.
- _screens : un écran du jeu (une scène...)
- _scene_objects : chaque objet posé sur une scène (personnage,
décor, fond, widgets d'interface...)
- _flow_nodes/_flow_edges : le graphe de logique de chaque écran
Ce fichier est désormais un package (voir les sous-dossiers) organisé
selon "un fichier = une fonction, un dossier = une responsabilité" ; ce
@@ -37,20 +18,10 @@ utilisée par app.py et les templates, afin que `import screens` continue
de fonctionner à l'identique.
"""
from .widgets.registry import WIDGETS
from .widgets.container_widgets import CONTAINER_WIDGETS
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
from .widgets.widget_meta import widget_meta
from .widgets.controls_with_values import controls_with_values
from .widgets.data_definition_options import data_definition_options
from .data_definitions.data_definition_options import data_definition_options
from .labels.trigger_labels import TRIGGER_LABELS
from .labels.action_type_labels import ACTION_TYPE_LABELS
from .labels.element_action_properties import (
ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS,
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
)
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
@@ -78,7 +49,6 @@ from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
)
from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
@@ -88,6 +58,8 @@ from .rendering.collision_rules import (
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
)
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
from .rendering.scene_object_names import list_named_scene_object_names
from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES
from .screens_repo.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens
@@ -97,26 +69,13 @@ from .screens_repo.rename_screen import rename_screen
from .screens_repo.delete_screen import delete_screen
from .screens_repo.move_screen import move_screen
from .screens_repo.screens_referencing import screens_referencing
from .screens_repo.set_screen_aspect import set_screen_aspect
from .screens_repo.set_scene_size import set_scene_size
from .screens_repo.set_screen_background_music import set_screen_background_music
from .elements.list_elements import list_elements
from .elements.get_element import get_element
from .elements.add_element import add_element
from .elements.add_tab import add_tab
from .elements.update_element_geometry import update_element_geometry
from .elements.reorder_element import reorder_element
from .elements.move_element_to_container import move_element_to_container
from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element
from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element
from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_element
from .elements.set_personnage_data import set_personnage_data
from .scenes.ensure_scene_schema import ensure_scene_schema
from .scenes.add_scene_object import add_scene_object
from .scenes.list_scene_objects import list_scene_objects
from .scenes.resolve_scene_world_size import resolve_scene_world_size
from .scenes.get_scene_object import get_scene_object
from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .scenes.delete_scene_object import delete_scene_object
@@ -125,21 +84,10 @@ from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.set_scene_object_role import set_scene_object_role
from .scenes.set_scene_object_collision import set_scene_object_collision
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
from .scenes.set_scene_object_name import set_scene_object_name
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
from .scenes.render_scene_object import render_scene_object
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .elements.element_label import element_label
from .element_types.list_element_types import list_element_types
from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen
from .element_types.create_element_type import create_element_type
from .element_types.update_element_type import update_element_type
from .element_types.element_type_usage_count import element_type_usage_count
from .element_types.delete_element_type import delete_element_type
from .legacy_actions.list_actions import list_actions
from .legacy_actions.add_action import add_action
from .legacy_actions.get_action import get_action
from .legacy_actions.delete_action import delete_action
from .flow.ensure_flow_schema import ensure_flow_schema
from .flow.list_flow_nodes import list_flow_nodes
-1
View File
@@ -1 +0,0 @@
"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...)."""
@@ -1,21 +0,0 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
from ..screens_repo.create_screen import create_screen
def create_element_type(slug, name, icon, bound_definition_id=None):
"""Un élément de jeu est un conteneur réutilisable : sa "recette" (les
éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle
caché, créé ici en même temps que l'entrée de catalogue — on le
remplit ensuite exactement comme le contenu de n'importe quel
conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle."""
ensure_schema(slug)
template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True)
conn = db.connect(slug)
conn.execute(
"INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)",
(name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None),
)
conn.commit()
conn.close()
@@ -1,15 +0,0 @@
import db
def delete_element_type(slug, element_type_id):
conn = db.connect(slug)
row = conn.execute(
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
).fetchone()
conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,))
if row and row["template_screen_id"]:
# Supprime aussi l'écran-modèle caché associé — son propre contenu
# (_screen_elements) part avec lui via ON DELETE CASCADE.
conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],))
conn.commit()
conn.close()
@@ -1,10 +0,0 @@
import db
def element_type_usage_count(slug, element_type_id):
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,)
).fetchone()["c"]
conn.close()
return count
@@ -1,16 +0,0 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
def get_element_type_by_template_screen(slug, screen_id):
"""L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST
cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur
d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu
(bound_definition_id, réglé à la création — voir element_types.html)
sans devoir tout lister soi-même."""
ensure_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone()
conn.close()
return dict(row) if row else None
-16
View File
@@ -1,16 +0,0 @@
from .load_template_tree import _load_template_tree
def _is_overlay_only_element_type(slug, element_type_id):
"""True si le modèle d'un élément de jeu N'EST QU'une "Superposition /
boîte de dialogue" (un seul élément de premier niveau, de ce widget) —
utilisé à deux endroits qui doivent s'accorder : render_element_html.py
(court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui
resterait sinon visible en permanence) et element_style_filter.py (le
cadre .canvasElement/.playElement de cet exemplaire ne doit pas non
plus poser de z-index, pour la même raison qu'une superposition posée
directement — voir son propre commentaire)."""
template_tree = _load_template_tree(slug, element_type_id)
if not template_tree:
return False
return {c.get("widget") for c in template_tree["top"]} == {"superposition"}
@@ -1,19 +0,0 @@
import json
import db
from ..screens_repo.ensure_schema import ensure_schema
def list_element_types(slug):
ensure_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["default_style"] = json.loads(d["default_style"] or "{}")
d["default_attributes"] = json.loads(d["default_attributes"] or "{}")
result.append(d)
return result
@@ -1,49 +0,0 @@
import json
import db
def _load_template_tree(slug, element_type_id):
"""Charge le contenu (éléments de premier niveau + descendants) de
l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier
quoi que ce soit en base) — utilisé quand un Répéteur de données affiche
cet élément de jeu comme modèle de ligne (voir _render_repeater) : on
relit et on rejoue son arbre à chaque ligne de données, avec les
{{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou
plus) d'écran-modèle valide."""
conn = db.connect(slug)
et_row = conn.execute(
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
).fetchone()
if not et_row or not et_row["template_screen_id"]:
conn.close()
return None
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(et_row["template_screen_id"],),
).fetchall()
conn.close()
elements = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
elements.append(d)
children_map = {}
for d in elements:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
top = [d for d in elements if not d.get("parent_id")]
if not top:
return None
# Astuce de rendu : ces éléments sont "de premier niveau" dans leur
# écran-modèle (parent_id NULL en base, donc positionnés en absolu par
# défaut), mais une fois le modèle réutilisé ailleurs (ligne de
# Répéteur), ils doivent se comporter comme des enfants en flux normal,
# pas comme des boîtes positionnées en absolu sur un écran plein. On leur
# donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien
# que pour ce rendu éphémère — _style_string ne teste que sa présence,
# jamais sa valeur exacte, et cette copie n'est jamais enregistrée.
for t in top:
t["parent_id"] = t.get("parent_id") or "template-root"
return {"top": top, "children_map": children_map}
@@ -1,13 +0,0 @@
import db
def update_element_type(slug, element_type_id, name=None, bound_definition_id=None):
conn = db.connect(slug)
if name:
conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id))
conn.execute(
"UPDATE _element_types SET bound_definition_id = ? WHERE id = ?",
(int(bound_definition_id) if bound_definition_id else None, element_type_id),
)
conn.commit()
conn.close()
-1
View File
@@ -1 +0,0 @@
"""Éléments posés sur un écran (_screen_elements)."""
-87
View File
@@ -1,87 +0,0 @@
import json
import db
from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
"""parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur
de cet élément parent (qui doit être un conteneur/répéteur/groupe de
champs) plutôt que directement sur l'écran — sa position/taille ne sert
alors plus (il suit le flux du parent), mais on garde des valeurs par
défaut pour rester cohérent si jamais il en sort un jour.
element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
comme n'importe quel widget), qui garde juste une référence vers cet
élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
copié en base : il est rechargé EN DIRECT à chaque affichage (voir
render_element_html.py), exactement comme un modèle de ligne de
Répéteur (voir _load_template_tree/_render_repeater). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un — au prix de ne plus pouvoir personnaliser
individuellement le CONTENU d'un exemplaire précis (sa position/taille
sur l'écran, elles, restent propres à chaque exemplaire, comme
d'habitude)."""
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
if element_type_id:
types = {t["id"]: t for t in list_element_types(slug)}
et = types.get(int(element_type_id))
if et:
widget_key = et.get("default_widget") or widget_key
meta = widget_meta(widget_key)
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
# création (ex: une image démarre avec "cover" pour se recadrer
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
# de dialogue" (render_element_html.py décide alors, au rendu, de
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
# démarre masqué par défaut, exactement comme une superposition
# posée directement (voir default_style_for_widget.py) — sinon un
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
# sa pose, avant même qu'une action ne l'affiche.
style["display"] = "none"
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchone()["m"]
# Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT
# SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le
# précédent et le recouvre entièrement — ce qui peut donner l'impression
# trompeuse qu'un "élément indésirable" vient d'apparaître et d'en
# cacher un autre (le plus souvent après une confusion entre "Ajouter un
# élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous
# les deux un nouvel élément mais pas au même endroit dans l'arbre).
x = y = 10
if not parent_id:
top_level_count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL",
(screen_id,),
).fetchone()["c"]
offset = (top_level_count % 8) * 4
x = y = 10 + offset
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes),
x, y, width, height, (max_z or 0) + 1,
json.dumps(style),
),
)
element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return element_id
-19
View File
@@ -1,19 +0,0 @@
import db
from .add_element import add_element
from .rename_element import rename_element
def add_tab(slug, onglets_id, screen_id):
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
modifiable ensuite comme celui de n'importe quel élément."""
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
).fetchone()["c"]
conn.close()
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
rename_element(slug, tab_id, f"Onglet {count + 1}")
return tab_id
-50
View File
@@ -1,50 +0,0 @@
import db
from ..animations.ensure_animation_schema import ensure_animation_schema
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def delete_element(slug, element_id):
ensure_flow_schema(slug)
ensure_animation_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
# élément"/action "Modifier cet élément"/condition de collision — voir
# ci-dessous...) ou une ancienne action du système _actions (conservé
# pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES
# DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces
# colonnes (trigger_element_id/target_element_id) n'ont volontairement
# PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être
# supprimé "par erreur" en cascade depuis un nœud de logique qu'on
# modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir
# db/connection.py) fait échouer la suppression elle-même avec
# "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour
# prévenir l'utilisateur AVANT qu'il confirme, plutôt que de supprimer
# ces nœuds en silence.
#
# cond_element_a/cond_element_b (Phase 5, condition de collision) : ni
# contrainte FK ni ON DELETE CASCADE (voir ensure_flow_schema.py), mais
# un nœud qui compare le rectangle de CET élément à un autre n'a pas
# plus de sens que si c'était trigger_element_id/target_element_id —
# supprimé ici pour la même raison, pas parce que la base l'exigerait.
conn.execute(
f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
OR target_element_id IN ({placeholders})
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
ids + ids + ids + ids,
)
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
# _animation_clips.element_id n'a plus de contrainte FK (voir
# ensure_animation_schema.py — réutilisé aussi par les objets de scène
# jeu_2d) : nettoyage manuel, même raison que ci-dessus.
conn.execute(f"DELETE FROM _animation_clips WHERE element_id IN ({placeholders})", ids)
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
conn.commit()
conn.close()
-60
View File
@@ -1,60 +0,0 @@
import db
def duplicate_element(slug, element_id):
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
de l'arborescence n'a pas à faire choisir où poser la copie, elle
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
sélectionner ensuite), ou None si l'élément n'existe pas."""
conn = db.connect(slug)
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not root_row:
conn.close()
return None
root = dict(root_row)
source = [
dict(r) for r in conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
).fetchall()
]
conn.close()
def copy_one(src, new_parent_id, is_root):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
).fetchone()["m"]
x, y = src["x"], src["y"]
if is_root and not new_parent_id:
# Petit décalage pour que la copie ne recouvre pas exactement
# l'original (qui serait alors invisible en dessous).
x = min(100, (x or 0) + 4)
y = min(100, (y or 0) + 4)
name = src.get("name")
if is_root and name:
name = f"{name} (copie)"
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
src["content"], src["attributes"], x, y, src["width"], src["height"],
(max_z or 0) + 1, src["style"],
),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
def walk(src_parent_id, new_parent_id):
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
new_child_id = copy_one(child, new_parent_id, is_root=False)
walk(child["id"], new_child_id)
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
walk(root["id"], new_root_id)
return new_root_id
@@ -1,23 +0,0 @@
def element_descendant_ids(conn, screen_id, root_id):
"""Id de cet élément et de TOUS ses descendants — ceux qu'une
suppression en cascade (parent_id ON DELETE CASCADE, voir
ensure_schema.py) supprime automatiquement en même temps que lui.
Partagé entre delete_element.py (nettoyage des références de la
Logique de la scène avant suppression) et flow_nodes_referencing_
element.py (pour prévenir l'utilisateur AVANT qu'il confirme la
suppression, voir routes/elements/element_delete_impact.py)."""
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchall()
children_of = {}
for r in rows:
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
ids = [root_id]
stack = [root_id]
while stack:
current = stack.pop()
for child_id in children_of.get(current, []):
ids.append(child_id)
stack.append(child_id)
return ids
-10
View File
@@ -1,10 +0,0 @@
from ..widgets.registry import WIDGETS
def element_label(el, widgets_catalog=None):
"""Nom lisible d'un élément pour les listes/menus : son nom personnalisé
s'il en a un, sinon le libellé de son widget (ex: "Image")."""
catalog = widgets_catalog if widgets_catalog is not None else WIDGETS
if el.get("name"):
return el["name"]
return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?"))
@@ -1,30 +0,0 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def flow_nodes_referencing_element(slug, element_id):
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
élément", action "Modifier cet élément"/"Activer cet onglet", condition
de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES
DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément
(voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT
qu'il confirme la suppression (voir la modale de confirmation dans
screen_edit.html), plutôt que de les supprimer en silence."""
ensure_flow_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return 0
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
count = conn.execute(
f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
OR target_element_id IN ({placeholders})
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
ids + ids + ids + ids,
).fetchone()["c"]
conn.close()
return count
-15
View File
@@ -1,15 +0,0 @@
import json
import db
def get_element(slug, element_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
return d
-95
View File
@@ -1,95 +0,0 @@
import json
import db
from ..rendering.render_element_html import render_element_html
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def list_elements(slug, screen_id, enforce_visibility=False, player_id=db.PLAYER_SHARED):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
celui d'un conteneur/répéteur/groupe de champs inclut, en le générant
récursivement, le HTML de tout ce qui est posé à l'intérieur — un
élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le
rendered_html de son ancêtre de premier niveau, utilisé pour le rendu
HTML initial de la page ; une fois dans le sien propre) : c'est ce
dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/
refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué
(ex. un panneau de détail posé dans un conteneur) sans reconstruire tout
son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce
changement, seuls les éléments de premier niveau recevaient un
rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui
retombait donc toujours sur la régénération de son ancêtre de premier
niveau, quelle que soit la finesse du ciblage côté JS.
enforce_visibility : True uniquement pour le mode jouable (voir
full_game_payload.py) — applique la "condition de visibilité" de chaque
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
qui doit continuer à montrer/sélectionner un élément même si sa
condition serait actuellement fausse.
player_id (état par joueur, Phase 1) : posé dans ctx["_forge_player_id"]
pour tout le rendu en aval (Répéteur/Jauge/Donnée liée/condition de
visibilité) — quel joueur consulte cet écran, pour qu'un objet/une
variable per_player affiche SA valeur (voir db/global_vars/,
db/rows/)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
).fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
result.append(d)
# Descendant d'un exemplaire d'élément de jeu (element_type_id réglé sur
# un ancêtre) : un ancien enfant copié en base par l'ex-instantiate_
# template_tree (mécanisme retiré, voir add_element.py) — son contenu
# vient désormais TOUJOURS en direct de l'écran-modèle au moment du
# rendu (voir render_element_html.py/_render_element_type_children),
# jamais de ces lignes, qui peuvent encore traîner en base pour un
# exemplaire posé avant ce changement. Exclu ici pour ne plus jamais
# apparaître sélectionnable dans l'arborescence de l'éditeur — sinon
# ses réglages, plus jamais synchronisés avec le modèle, donnaient
# l'impression trompeuse que "les propriétés ne sont pas prises en
# compte" (une taille de texte modifiée dans le modèle ne se reflétant
# jamais sur cette ligne fantôme, toujours sélectionnable à l'écran).
by_id = {d["id"]: d for d in result}
def _has_linked_ancestor(d):
seen = set()
parent_id = d.get("parent_id")
while parent_id and parent_id not in seen:
seen.add(parent_id)
parent = by_id.get(parent_id)
if not parent:
return False
if parent.get("element_type_id"):
return True
parent_id = parent.get("parent_id")
return False
result = [d for d in result if not _has_linked_ancestor(d)]
children_map = {}
for d in result:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True, "_forge_player_id": player_id} if enforce_visibility else None
for d in result:
# Un exemplaire de premier niveau d'un élément de jeu dont le
# modèle N'EST QU'une superposition : son cadre de positionnement
# (.canvasElement/.playElement, voir filters/element_style_filter.py)
# ne doit pas non plus poser de z-index, pour la même raison qu'une
# superposition posée directement (sinon elle crée un contexte
# d'empilement CSS qui piège le z-index:9999 de la superposition
# rendue à l'intérieur — voir element_style_filter.py).
if d.get("element_type_id") and not d.get("parent_id"):
d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"])
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
return result
@@ -1,62 +0,0 @@
import db
from ..widgets.container_widgets import CONTAINER_WIDGETS
def move_element_to_container(slug, element_id, new_parent_id):
"""Déplace element_id pour qu'il devienne un enfant de new_parent_id
(glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en
DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme
d'habitude par glisser-déposer entre frères et sœurs (voir
reorder_element.py). Refuse silencieusement (ne touche à rien) si :
new_parent_id n'est pas un widget conteneur, si c'est element_id
lui-même, ou si c'est un de ses propres descendants (ça créerait un
cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)",
(element_id, new_parent_id),
).fetchall()
by_id = {r["id"]: r for r in rows}
el = by_id.get(element_id)
target = by_id.get(new_parent_id)
if not el or not target or el["screen_id"] != target["screen_id"]:
conn.close()
return
if target["widget"] not in CONTAINER_WIDGETS:
conn.close()
return
if new_parent_id == element_id:
conn.close()
return
# Cycle : new_parent_id ne doit pas être element_id, ni un descendant de
# element_id (direct ou indirect) — sinon element_id finirait posé à
# l'intérieur de sa propre arborescence.
children_of = {}
for r in conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchall():
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
stack = list(children_of.get(element_id, []))
descendants = set()
while stack:
node = stack.pop()
if node in descendants:
continue
descendants.add(node)
stack.extend(children_of.get(node, []))
if new_parent_id in descendants:
conn.close()
return
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchone()["m"]
conn.execute(
"UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?",
(new_parent_id, (max_z or 0) + 1, element_id),
)
conn.commit()
conn.close()
-8
View File
@@ -1,8 +0,0 @@
import db
def rename_element(slug, element_id, name):
conn = db.connect(slug)
conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id))
conn.commit()
conn.close()
-37
View File
@@ -1,37 +0,0 @@
import db
def reorder_element(slug, screen_id, element_id, target_id, position):
"""Déplace element_id juste avant ("before") ou juste après ("after")
target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même
parent_id — sinon on ne touche à rien, pour ne jamais reparenter par
erreur via le glisser-déposer de l'arborescence). L'ordre est porté par
z_index, partagé par tous les éléments de l'écran (pas juste les frères
et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel
(z_index, id) pour rester cohérent partout, pas seulement dans ce
conteneur."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(screen_id,),
).fetchall()
by_id = {r["id"]: r["parent_id"] for r in rows}
if element_id not in by_id or target_id not in by_id:
conn.close()
return
if by_id[element_id] != by_id[target_id]:
conn.close()
return
order = [r["id"] for r in rows]
order.remove(element_id)
target_index = order.index(target_id)
insert_at = target_index + 1 if position == "after" else target_index
order.insert(insert_at, element_id)
conn.executemany(
"UPDATE _screen_elements SET z_index = ? WHERE id = ?",
[(i + 1, eid) for i, eid in enumerate(order)],
)
conn.commit()
conn.close()
-227
View File
@@ -1,227 +0,0 @@
import json
import db
from ..widgets.widget_meta import widget_meta
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from ..clause_list_codec import encode_clauses
from .get_element import get_element
def _hex_to_rgb(hex_color):
h = (hex_color or "").lstrip("#")
if len(h) != 6:
return (0, 0, 0)
try:
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
return (0, 0, 0)
def _is_default_value(control, raw_value):
"""Compare la valeur reçue du formulaire à la valeur par défaut du
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
savoir si le champ a simplement été renvoyé tel quel sans être
personnalisé."""
default = control.get("default", "")
if control["type"] == "slider":
unit = control.get("unit", "")
default = f"{default}{unit}" if default not in (None, "") else ""
return str(raw_value) == str(default)
def save_element_controls(slug, element_id, form):
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
par le panneau de propriétés (un seul formulaire, un champ par réglage)
et met à jour content / attributs / style en conséquence."""
el = get_element(slug, element_id)
if not el:
return
meta = widget_meta(el["widget"])
content = el["content"]
attributes = dict(el["attributes"])
style = dict(el["style"])
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
for control in meta.get("controls", []):
ctype = control["type"]
field_name = "ctrl_" + control["key"]
if ctype == "toggle_token":
_kind, name = control["target"].split(":", 1)
toggle_groups.setdefault(name, set())
if form.get(field_name) is not None:
toggle_groups[name].add(control["token"])
continue
if ctype == "clause_list":
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
# "_valeur[]"), reconstituées ici en une liste de clauses par
# position (zip) — même principe que field_name[]/field_type[]
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
champs = form.getlist(field_name + "_champ[]")
operateurs = form.getlist(field_name + "_operateur[]")
valeurs = form.getlist(field_name + "_valeur[]")
clauses = [
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
"valeur": valeurs[i] if i < len(valeurs) else ""}
for i, champ in enumerate(champs)
]
encoded = encode_clauses(clauses)
if encoded:
attributes[control["clauses_attr"]] = encoded
else:
attributes.pop(control["clauses_attr"], None)
combinateur = form.get(field_name + "_combinateur") or "et"
if combinateur == "ou":
attributes[control["combinator_attr"]] = combinateur
else:
attributes.pop(control["combinator_attr"], None)
continue
if ctype == "preset":
chosen = form.get(field_name, control.get("default"))
opt = next((o for o in control["options"] if o[0] == chosen), None)
if opt:
for k, v in opt[2].items():
if v in (None, ""):
style.pop(k, None)
else:
style[k] = v
if (
control["key"] == "visibilite" and chosen == "visible"
and el["widget"] not in LAYOUT_CAPABLE_WIDGETS
):
# "Visible" ne touche volontairement pas "display" (voir
# visibility_control.py) : sur un widget qui A un
# réglage "Disposition interne" (LAYOUT_CONTROLS,
# traité avant celui-ci sur le MÊME enregistrement,
# voir widget_meta.py), ce contrôle réaffirme lui-même
# un "display" non-"none" (flex/etc), donc "Visible" n'a
# rien à faire. Mais un widget SANS ce contrôle (ex.
# "superposition") n'a PERSONNE d'autre pour l'effacer :
# un "display:none" laissé par un "Masqué" précédent (ou
# par le réglage par défaut à la création, voir
# default_style_for_widget.py) restait donc bloqué
# masqué pour toujours, quel que soit le nombre de fois
# où "Visible" était ensuite choisi.
style.pop("display", None)
continue
if ctype == "scale":
try:
val = float(form.get(field_name, control.get("default", 1)))
except (TypeError, ValueError):
val = 1.0
if abs(val - 1) < 1e-6:
style.pop("transform", None)
else:
style["transform"] = f"scale({val})"
continue
if ctype == "size":
# Un seul champ CSS ("width"/"height") mais deux entrées de
# formulaire (valeur + unité) — voir size_override_controls.py
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
_kind, name = control["target"].split(":", 1)
try:
num = float(form.get(field_name + "_value") or 0)
except (TypeError, ValueError):
num = 0.0
unit = form.get(field_name + "_unit", "%")
if unit not in ("px", "%"):
unit = "%"
if num <= 0:
style.pop(name, None)
else:
num_str = str(int(num)) if num == int(num) else str(num)
style[name] = f"{num_str}{unit}"
continue
if ctype == "shadow":
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
# <input type="color"> seul ne portant pas de canal alpha).
_kind, name = control["target"].split(":", 1)
defaults = control.get("default", {})
def _num(suffix, fallback):
try:
return float(form.get(field_name + suffix, fallback))
except (TypeError, ValueError):
return fallback
x = _num("_x", defaults.get("x", 0))
y = _num("_y", defaults.get("y", 4))
blur = _num("_blur", defaults.get("blur", 12))
spread = _num("_spread", defaults.get("spread", 0))
opacity = _num("_opacity", defaults.get("opacity", 0.4))
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
if x == 0 and y == 0 and blur == 0 and spread == 0:
style.pop(name, None)
else:
r, g, b = _hex_to_rgb(color)
def _px(n):
return str(int(n)) if n == int(n) else str(n)
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
continue
target = control["target"]
if ctype == "checkbox":
checked = form.get(field_name) is not None
raw_value = control["true_value"] if checked else control["false_value"]
else:
raw_value = form.get(field_name, control.get("default", ""))
if ctype == "slider":
unit = control.get("unit", "")
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
if target == "content":
content = raw_value
else:
kind, name = target.split(":", 1)
if kind == "class":
# "class:xxx" : un jeton de classe CSS externe (ex. un
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
# voir _visible_attrs.py qui les rassemble avec la classe de
# base du widget (fixed_attrs) au moment du rendu.
store, name = attributes, "_class_" + name
else:
store = attributes if kind == "attr" else style
if raw_value in (None, ""):
store.pop(name, None)
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
# Ce réglage n'a jamais été personnalisé (la clé est absente
# du style/des attributs stockés) et la valeur reçue est
# simplement celle par défaut affichée dans le champ (un
# <input type="color"> ou un slider a TOUJOURS une valeur à
# l'écran, même quand rien n'a été choisi). Comme le
# panneau de propriétés envoie TOUS les réglages du widget
# en un seul formulaire, sauvegarder n'importe quel autre
# champ (ex. changer la disposition) écrirait sinon cette
# valeur par défaut "en dur" dans l'élément — ce qui faisait
# apparaître par exemple une couleur de fond qui n'avait
# jamais été demandée. On laisse donc ce réglage "non
# défini" tant qu'il n'a pas été explicitement changé.
pass
else:
store[name] = raw_value
for prop, tokens in toggle_groups.items():
if tokens:
style[prop] = " ".join(sorted(tokens))
else:
style.pop(prop, None)
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
(content, json.dumps(attributes), json.dumps(style), element_id),
)
conn.commit()
conn.close()
-20
View File
@@ -1,20 +0,0 @@
import json
import db
def set_personnage_data(slug, element_id, data):
"""Fusionne _personnage_data (voir screens/rendering/personnage_data.py)
dans les attributs déjà stockés de cet élément — remplace la source
(Forge/personnalisée) et son contenu (personnage choisi, ou animations
importées) en un seul appel, jamais les autres attributs de l'élément."""
conn = db.connect(slug)
row = conn.execute("SELECT attributes FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
attributes = json.loads(row["attributes"] or "{}")
attributes["_personnage_data"] = json.dumps(data)
conn.execute("UPDATE _screen_elements SET attributes = ? WHERE id = ?", (json.dumps(attributes), element_id))
conn.commit()
conn.close()
@@ -1,14 +0,0 @@
import db
def update_element_geometry(slug, element_id, x, y, width, height):
"""Utilisé par le glisser-déposer / redimensionnement (et par les champs
Position & taille du panneau de droite) — ne touche qu'à la position et
à la taille, rien d'autre."""
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?",
(x, y, width, height, element_id),
)
conn.commit()
conn.close()
+2 -2
View File
@@ -11,7 +11,7 @@ def ensure_flow_schema(slug):
node_type TEXT NOT NULL,
x REAL NOT NULL DEFAULT 40,
y REAL NOT NULL DEFAULT 40,
trigger_element_id INTEGER REFERENCES _screen_elements(id),
trigger_element_id INTEGER,
trigger_event TEXT,
cond_definition_id INTEGER,
cond_row_id INTEGER,
@@ -21,7 +21,7 @@ def ensure_flow_schema(slug):
cond_value TEXT,
action_type TEXT,
target_screen_id INTEGER REFERENCES _screens(id),
target_element_id INTEGER REFERENCES _screen_elements(id),
target_element_id INTEGER,
element_property TEXT,
element_value TEXT,
target_definition_id INTEGER,
+7 -63
View File
@@ -1,66 +1,10 @@
# ---------- Actions : modifier un élément ----------
ELEMENT_ACTION_PROPERTIES = [
("visibilite", "Visibilité"),
# Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un
# créateur de reconstruire l'ancien comportement "texte affiché au
# survol" (et bien d'autres) à la main dans la Logique de la scène —
# poser "Modifier un élément → Contenu" sur un déclencheur "Au survol"
# d'un AUTRE élément. La valeur est un texte littéral (pas encore de
# binding vers un champ d'objet ici — voir applyOpenRowBindings pour le
# mécanisme équivalent, propre à "Ouvrir la ligne cliquée").
("contenu", "Contenu — texte affiché"),
("bg_color", "Couleur de fond"),
("border_color", "Couleur de bordure"),
("width", "Largeur en %"),
("height", "Hauteur en %"),
# 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
# clignote autour de l'élément, pour attirer l'attention du joueur vers
# ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) —
# sans avoir à poser une bordure de couleur togglée à la main. Voir
# ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html.
("surbrillance", "Surbrillance — liseré clignotant"),
# 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
# ignore les clics, DISTINCT de "invisible" (qui garde sa place mais
# disparaît visuellement) — un bouton "Répondre" qui reste visible mais
# devient inerte une fois le mail traité, par exemple. Voir
# ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
# de se redéclencher tant que l'élément est désactivé).
("desactive", "Désactivé — grisé, ignore les clics"),
# Phase 5 (moteur) — déplacement d'élément : "absolu" fixe la position
# (en % du cadre, comme Largeur/Hauteur ci-dessus — les éléments sont
# déjà positionnés ainsi, voir filters/element_style_filter.py) ;
# "relatif" AJOUTE la valeur à la position actuelle plutôt que de
# l'écraser (voir applyElementProperty() dans static/js/play/actions.js)
# — combiné à un déclencheur clavier (Phase 3), ça permet un
# personnage/objet qui se déplace pas à pas à chaque appui de touche.
("pos_x", "Position horizontale en % (absolue)"),
("pos_y", "Position verticale en % (absolue)"),
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
("pos_y_relatif", "Déplacer verticalement de... % (relatif)"),
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
# l'élément (utile pour un personnage qui se déplace au clavier, voir
# applyElementProperty() dans static/js/play/actions.js — retourne
# l'élément en miroir plutôt que de nécessiter une 2e feuille de
# sprites "vue de dos").
("orientation", "Orientation — sens du personnage"),
]
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
ELEMENT_SURBRILLANCE_VALUES = [
("on", "Activer la surbrillance"),
("off", "Désactiver la surbrillance"),
("toggle", "Basculer activée / désactivée"),
]
ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES)
ELEMENT_DESACTIVE_VALUES = [
("on", "Désactiver — griser, ignorer les clics"),
("off", "Réactiver"),
("toggle", "Basculer désactivé / réactivé"),
]
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
# ---------- Actions : orientation d'un objet de scène (jeu_2d) ----------
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
# l'objet (utile pour un personnage qui se déplace au clavier, voir
# applyElementProperty() dans static/js/play/actions.js — retourne
# l'élément en miroir plutôt que de nécessiter une 2e feuille de sprites
# "vue de dos"). Repris par OBJECT_ACTION_PROPERTIES (screens/scenes/
# flow_palette.py) et routes/scenes/scene_edit_view.py.
ELEMENT_ORIENTATION_VALUES = [
("droite", "Faire face à droite"),
-2
View File
@@ -1,2 +0,0 @@
"""Ancien système d'actions par élément (_actions) — conservé pour ne rien
casser sur les jeux déjà créés, remplacé par le module flow/."""
-25
View File
@@ -1,25 +0,0 @@
import db
def add_action(
slug, element_id, trigger_event, action_type, target_screen_id=None,
target_element_id=None, element_property=None, element_value=None,
target_definition_id=None, target_row_id=None, target_field=None,
data_operation=None, data_value=None,
):
conn = db.connect(slug)
conn.execute(
"""INSERT INTO _actions
(element_id, trigger_event, action_type, target_screen_id,
target_element_id, element_property, element_value,
target_definition_id, target_row_id, target_field, data_operation, data_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
element_id, trigger_event, action_type, target_screen_id or None,
target_element_id or None, element_property or None, element_value or None,
target_definition_id or None, target_row_id or None, target_field or None,
data_operation or None, data_value or None,
),
)
conn.commit()
conn.close()
-8
View File
@@ -1,8 +0,0 @@
import db
def delete_action(slug, action_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,))
conn.commit()
conn.close()
-8
View File
@@ -1,8 +0,0 @@
import db
def get_action(slug, action_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone()
conn.close()
return dict(row) if row else None
-8
View File
@@ -1,8 +0,0 @@
import db
def list_actions(slug, element_id):
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
+52 -85
View File
@@ -1,17 +1,15 @@
import db
from ..screens_repo.list_screens import list_screens
from ..elements.list_elements import list_elements
from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
from ..element_types.load_template_tree import _load_template_tree
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings
from ..rendering.collision_rules import resolve_collision_rules
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
@@ -44,52 +42,43 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
flows = {}
animations = {}
for s in screens_:
if s["kind"] == "jeu_2d":
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches et son rôle déjà résolus
# (défauts comblés) pour que le runtime client n'ait
# aucune logique de repli à dupliquer — seul un
# personnage "joueur" est déplacé/suivi par la caméra.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
o["personnage_role"] = resolve_personnage_role(o)
# Boîte de collision (voir static/js/play/conditions.js::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# seulement "personnage" — un décor/fond peut aussi servir
# d'obstacle.
o["collision"] = resolve_collision_settings(o)
# Règles "à la collision/dans un périmètre -> action" (voir
# "🧩 Collision", templates/scene_edit.html, et
# static/js/play/collision-rules-controller.js pour leur
# exécution) — jamais sur "fond" (jamais une cible), ni sur
# le personnage "joueur" lui-même (ce sont TOUJOURS les
# AUTRES objets qui réagissent à SA présence, jamais
# l'inverse).
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
o["collision_rules"] = resolve_collision_rules(o)
payload_screens.append({**s, "elements": objects})
else:
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
payload_screens.append({**s, "elements": elements})
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches et son rôle déjà résolus
# (défauts comblés) pour que le runtime client n'ait
# aucune logique de repli à dupliquer — seul un
# personnage "joueur" est déplacé/suivi par la caméra.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
o["personnage_role"] = resolve_personnage_role(o)
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
# Widgets d'interface (voir "🖥️ Interface",
# screens/rendering/dialogue_box_style.py) : ni
# collision, ni cible/source de règle — juste leur
# style, lu par static/js/play/dialogue-box-controller.js.
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
continue
# Boîte de collision (voir static/js/play/conditions.js::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# seulement "personnage" — un décor/fond peut aussi servir
# d'obstacle.
o["collision"] = resolve_collision_settings(o)
# Règles "à la collision/dans un périmètre -> action" (voir
# "🧩 Collision", templates/scene_edit.html, et
# static/js/play/collision-rules-controller.js pour leur
# exécution) — jamais sur "fond" (jamais une cible), ni sur
# le personnage "joueur" lui-même (ce sont TOUJOURS les
# AUTRES objets qui réagissent à SA présence, jamais
# l'inverse).
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
o["collision_rules"] = resolve_collision_rules(o)
payload_screens.append({**s, "elements": objects})
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
# écrans, écrans-modèles compris (include_templates=True) — pas
# seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable
# (ex. "mail content") peut avoir son propre déclencheur/sa propre
# animation posée dans l'éditeur de SON écran-modèle (voir
# screen_edit.html) : sans ses entrées ici, gameData.flows/
# gameData.animations n'auraient jamais contenu cet écran-modèle côté
# client, et findTriggerNode()/collectAnimationClips() (templates/
# play.html) — pourtant déjà écrits pour les chercher — n'auraient
# jamais rien trouvé. On ne les ajoute PAS à payload_screens : un
# écran-modèle ne doit jamais être rendu comme un vrai <div
# class="playScreen"> (il n'est jamais affiché tel quel, seulement
# rechargé en direct à l'intérieur d'un élément qui l'utilise).
# Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
# écrans. Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
# affichage de la page de jeu (donc toujours à partir du personnage
# Forge ACTUELLEMENT assigné), plutôt que figées dans le nœud de flow/
# le clip de Timeline au moment où le créateur les a configurés : sans
@@ -99,24 +88,17 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
# voir runActionNode()/applyAnimationClip() qui font cette résolution
# à l'exécution à partir de gameData.personnage_animations plutôt que
# depuis un data_value/custom_keyframes qui contiendrait des frames
# déjà résolues). include_templates=True : un personnage peut vivre
# dans un écran-modèle d'élément de jeu réutilisable, comme les flows/
# animations ci-dessous.
# déjà résolues).
personnage_animations = {}
for s in list_screens(slug, include_templates=True):
for s in screens_:
flows[str(s["id"])] = {
"nodes": list_flow_nodes(slug, s["id"]),
"edges": list_flow_edges(slug, s["id"]),
}
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
if s["kind"] == "jeu_2d":
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage":
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
else:
for el in list_elements(slug, s["id"]):
if el.get("widget") == "personnage":
personnage_animations[str(el["id"])] = resolve_personnage_animations(el)
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage":
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
definitions = db.list_definitions(slug)
data = {}
@@ -156,30 +138,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
for f in full["fields"]
]
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
# aussi être joués à l'affichage de cet écran — sans quoi une animation
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
# Arbre complet (éléments + descendants) de l'écran-modèle de chaque
# élément de jeu réutilisable — voir screens/element_types/
# load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu
# créateur ET jeu publié en ligne). Nécessaire ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
# rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de
# Répéteur) puisse le rejouer sans base de données — inoffensif pour
# le runtime en ligne, qui l'ignore.
element_type_templates = {}
for type_id_str, template_screen_id in element_types.items():
if template_screen_id:
tree = _load_template_tree(slug, int(type_id_str))
if tree:
element_type_templates[type_id_str] = tree
# Instantané des variables globales, pour qu'un nœud Condition puisse en
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
@@ -198,9 +156,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
# ci-dessus sont déjà indexés par id, jamais par nom.
definition_names = {str(d["id"]): d["name"] for d in definitions}
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici
# pour que static/js/play/dialogue-box-controller.js puisse retrouver,
# à l'exécution d'une action "quete" (collision-rules-controller.js),
# le dialogue de la colonne correspondant au STATUT ACTUEL de la
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur.
quests = db.list_quests(slug)
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations,
"definition_names": definition_names, "element_type_templates": element_type_templates,
"definition_names": definition_names,
"quests": quests,
}
-14
View File
@@ -1,14 +0,0 @@
def _apply_ctx(text, ctx):
"""À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par
la valeur de ce champ pour la ligne en cours — appliqué au contenu ET
aux attributs de l'élément et de tous ses descendants."""
if not ctx or not text:
return text
for k, v in ctx.items():
# "_forge_play_mode" (voir render_element_html.py) est un indicateur
# interne posé dans ce même ctx, pas une valeur de champ {{...}} —
# à ignorer ici.
if k == "_forge_play_mode":
continue
text = text.replace("{{" + k + "}}", v)
return text
-10
View File
@@ -1,10 +0,0 @@
import html as html_lib
def _attr_string(attrs, extra_style):
parts = [f'style="{html_lib.escape(extra_style)}"']
for k, v in attrs.items():
if v in (None, "", False):
continue
parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"')
return " ".join(parts)

Some files were not shown because too many files have changed in this diff Show More