Merge pull request 'Dev' (#11) from dev into main
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -37,3 +37,7 @@ assets/background/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Documents internes/business (cadrage produit...) et état local de
|
||||||
|
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -33,4 +33,12 @@ def _open_browser():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
threading.Timer(1.0, _open_browser).start()
|
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)
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from filters.colname_filter import _colname
|
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
|
from filters.truncate_sentences_filter import _truncate_sentences
|
||||||
|
|
||||||
app.jinja_env.filters["colname"] = _colname
|
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
|
app.jinja_env.filters["truncatesentences"] = _truncate_sentences
|
||||||
|
|||||||
+5
-4
@@ -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 .custom_events.update_custom_event import update_custom_event
|
||||||
|
|
||||||
from .quests.constants import (
|
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.list_quests import list_quests
|
||||||
from .quests.get_quest import get_quest
|
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.update_quest import update_quest
|
||||||
from .quests.delete_quest import delete_quest
|
from .quests.delete_quest import delete_quest
|
||||||
from .quests.set_quest_dialogues import set_quest_dialogues
|
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__ = [
|
__all__ = [
|
||||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||||
@@ -106,7 +107,7 @@ __all__ = [
|
|||||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||||
"delete_global_variable_by_id",
|
"delete_global_variable_by_id",
|
||||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
"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",
|
"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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,48 +15,45 @@ rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
|
|||||||
routes/screens/screens_new.py)."""
|
routes/screens/screens_new.py)."""
|
||||||
import db
|
import db
|
||||||
|
|
||||||
DEFAULT_ONBOARDING_TYPE = "custom"
|
DEFAULT_ONBOARDING_TYPE = "rpg"
|
||||||
|
|
||||||
ONBOARDING_TYPES = {
|
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": {
|
"rpg": {
|
||||||
"label": "Jeu RPG",
|
"label": "Créer un jeu 2D ludique",
|
||||||
"tagline": "Ton monde, ton héros, ton scénario.",
|
"tagline": "Ton monde, ton héros, ton scénario.",
|
||||||
"icon": "🗺️",
|
"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 & 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",
|
"screen_kind": "jeu_2d",
|
||||||
"restricted": True,
|
"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,6 +1,6 @@
|
|||||||
from ..connection import connect
|
from ..connection import connect
|
||||||
|
|
||||||
DEFAULT_GAME_TYPE = "document"
|
DEFAULT_GAME_TYPE = "jeu_2d"
|
||||||
|
|
||||||
|
|
||||||
def get_game_type(slug):
|
def get_game_type(slug):
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
|
|||||||
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
|
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
|
||||||
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
|
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
|
||||||
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
|
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
|
||||||
QUEST_SPEAKER_CHOICES = ["joueur", "pnj"]
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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_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):
|
def sanitize_quest_dialogues(raw_dialogues):
|
||||||
"""Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne
|
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de
|
||||||
par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que
|
répliques de dialogue ({type: "dialogue", speaker, text} — le nom
|
||||||
resolve_collision_rules.sanitize_collision_rules : rejette tout
|
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur",
|
||||||
élément invalide plutôt que de lever, ne garde jamais un texte vide."""
|
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}
|
result = {status: [] for status in QUEST_STATUS_CHOICES}
|
||||||
if not isinstance(raw_dialogues, dict):
|
if not isinstance(raw_dialogues, dict):
|
||||||
return result
|
return result
|
||||||
@@ -15,16 +77,6 @@ def sanitize_quest_dialogues(raw_dialogues):
|
|||||||
raw_lines = raw_dialogues.get(status)
|
raw_lines = raw_dialogues.get(status)
|
||||||
if not isinstance(raw_lines, list):
|
if not isinstance(raw_lines, list):
|
||||||
continue
|
continue
|
||||||
cleaned = []
|
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
|
||||||
for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]:
|
result[status] = [line for line in cleaned if line]
|
||||||
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
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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 ""
|
|
||||||
@@ -252,6 +252,7 @@ def build_scorm_zip(slug):
|
|||||||
html = render_template(
|
html = render_template(
|
||||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
||||||
offline_mode=True, asset_url=lambda filename: "static/" + filename,
|
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 = _relativize_absolute_urls(html, slug)
|
||||||
html = _inline_icon_urls(html, payload["icon_data_uris"])
|
html = _inline_icon_urls(html, payload["icon_data_uris"])
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from . import element_types, element_type_edit, element_type_delete
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from flask import redirect, url_for
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/element-types/<int:element_type_id>/delete", methods=["POST"])
|
|
||||||
def element_type_delete(slug, element_type_id):
|
|
||||||
if screens.element_type_usage_count(slug, element_type_id) == 0:
|
|
||||||
screens.delete_element_type(slug, element_type_id)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/element-types/<int:element_type_id>/edit", methods=["POST"])
|
|
||||||
def element_type_edit(slug, element_type_id):
|
|
||||||
"""Renomme un élément de jeu et/ou change l'objet auquel il est lié —
|
|
||||||
son contenu (l'arbre d'éléments) se modifie séparément, via le lien
|
|
||||||
"✏️ Modifier le contenu" qui ouvre l'écran-modèle dans l'éditeur normal."""
|
|
||||||
name = request.form.get("name", "").strip() or None
|
|
||||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
|
||||||
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
|
|
||||||
def element_types(slug):
|
|
||||||
"""La création/gestion d'un élément de jeu se fait désormais depuis
|
|
||||||
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
|
|
||||||
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
|
|
||||||
création (barre de création repliable de cet onglet). Un GET direct
|
|
||||||
redirige vers le tableau de bord."""
|
|
||||||
if request.method == "POST":
|
|
||||||
name = request.form.get("name", "").strip()
|
|
||||||
icon = request.form.get("icon", "").strip() or "🧩"
|
|
||||||
bound_definition_id = request.form.get("bound_definition_id") or None
|
|
||||||
if name:
|
|
||||||
screens.create_element_type(slug, name, icon, bound_definition_id)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from . import (
|
|
||||||
element_add,
|
|
||||||
element_add_child,
|
|
||||||
element_geometry,
|
|
||||||
element_reorder,
|
|
||||||
element_move_to_container,
|
|
||||||
element_save,
|
|
||||||
element_delete,
|
|
||||||
element_delete_impact,
|
|
||||||
element_duplicate,
|
|
||||||
element_rename,
|
|
||||||
element_tab_add,
|
|
||||||
element_set_personnage_data,
|
|
||||||
)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
from core.sprite_gate import forbid_admin_only_character
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
|
|
||||||
def element_add(slug, screen_id):
|
|
||||||
widget = request.form.get("widget", "conteneur")
|
|
||||||
from_catalogue = widget == "__catalogue__"
|
|
||||||
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
|
||||||
if from_catalogue and not element_type_id:
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
|
||||||
if from_catalogue:
|
|
||||||
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
|
||||||
et = types.get(int(element_type_id))
|
|
||||||
widget = et["default_widget"] if et else "conteneur"
|
|
||||||
new_id = screens.add_element(slug, screen_id, widget, element_type_id)
|
|
||||||
icon_slug = request.form.get("icon_class")
|
|
||||||
if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS:
|
|
||||||
# Posé depuis la galerie d'icônes (voir le panneau de propriétés,
|
|
||||||
# section "Icônes") : l'icône choisie remplace celle posée par
|
|
||||||
# défaut à la création plutôt que de repartir d'une icône neutre que
|
|
||||||
# l'utilisateur devrait ensuite changer manuellement.
|
|
||||||
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
|
|
||||||
if widget == "personnage":
|
|
||||||
# Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement()
|
|
||||||
# dans tree-panels.js) : remplace le personnage Forge par défaut
|
|
||||||
# (male-adventurer, voir default_style_for_widget.py) par celui
|
|
||||||
# réellement cliqué.
|
|
||||||
forge_character = request.form.get("forge_character")
|
|
||||||
if forge_character and forge_character in screens.SPRITE_LIBRARY:
|
|
||||||
forbid_admin_only_character(forge_character)
|
|
||||||
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from flask import request, redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:parent_id>/children/add", methods=["POST"])
|
|
||||||
def element_add_child(slug, parent_id):
|
|
||||||
"""Ajoute un élément PHYSIQUEMENT à l'intérieur d'un conteneur, d'un
|
|
||||||
répéteur de données ou d'un groupe de champs déjà posé sur l'écran."""
|
|
||||||
parent = screens.get_element(slug, parent_id)
|
|
||||||
if not parent:
|
|
||||||
abort(404)
|
|
||||||
widget = request.form.get("widget", "conteneur")
|
|
||||||
from_catalogue = widget == "__catalogue__"
|
|
||||||
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
|
||||||
if from_catalogue and element_type_id:
|
|
||||||
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
|
||||||
et = types.get(int(element_type_id))
|
|
||||||
widget = et["default_widget"] if et else "conteneur"
|
|
||||||
elif from_catalogue:
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=parent_id))
|
|
||||||
new_id = screens.add_element(slug, parent["screen_id"], widget, element_type_id, parent_id=parent_id)
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=new_id))
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from flask import redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/delete", methods=["POST"])
|
|
||||||
def element_delete(slug, element_id):
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
screen_id = element["screen_id"]
|
|
||||||
parent_id = element.get("parent_id")
|
|
||||||
screens.delete_element(slug, element_id)
|
|
||||||
# Resélectionne le conteneur parent (s'il y en a un) après suppression,
|
|
||||||
# plutôt que de laisser le panneau de droite vide : sinon, supprimer un
|
|
||||||
# élément fait brusquement "sauter" l'éditeur vers un état sans rien de
|
|
||||||
# sélectionné.
|
|
||||||
if parent_id:
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=parent_id))
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from flask import jsonify
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
|
|
||||||
def element_delete_impact(slug, element_id):
|
|
||||||
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
|
|
||||||
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
|
|
||||||
Logique de la scène (déclencheur/action) seraient aussi supprimés en
|
|
||||||
même temps que l'élément — voir delete_element.py, qui les supprime
|
|
||||||
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
|
|
||||||
constraint failed", voir son commentaire)."""
|
|
||||||
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from flask import redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
|
|
||||||
def element_duplicate(slug, element_id):
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
new_id = screens.duplicate_element(slug, element_id)
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
from flask import request, jsonify
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/geometry", methods=["POST"])
|
|
||||||
def element_geometry(slug, element_id):
|
|
||||||
"""Appelé en AJAX pendant/à la fin d'un glisser-déposer, d'un
|
|
||||||
redimensionnement, ou d'une modification des champs Position & taille —
|
|
||||||
ne touche qu'à la position/taille, aucun rechargement de page nécessaire
|
|
||||||
pour que ça reste fluide."""
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
screens.update_element_geometry(
|
|
||||||
slug, element_id,
|
|
||||||
x=float(data["x"]), y=float(data["y"]),
|
|
||||||
width=float(data["width"]), height=float(data["height"]),
|
|
||||||
)
|
|
||||||
return jsonify({"ok": True})
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from flask import request, jsonify, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
|
|
||||||
def element_move_to_container(slug, element_id):
|
|
||||||
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
|
|
||||||
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
|
|
||||||
dessous — le fait passer enfant de ce conteneur, en dernière position
|
|
||||||
(voir move_element_to_container, qui refuse aussi silencieusement les
|
|
||||||
cas invalides : cible non-conteneur, ou cycle)."""
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
new_parent_id = int(data["parent_id"])
|
|
||||||
screens.move_element_to_container(slug, element_id, new_parent_id)
|
|
||||||
return jsonify({"ok": True})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from flask import request, redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/rename", methods=["POST"])
|
|
||||||
def element_rename(slug, element_id):
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
screens.rename_element(slug, element_id, request.form.get("name", ""))
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from flask import request, jsonify, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
|
|
||||||
def element_reorder(slug, element_id):
|
|
||||||
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
|
|
||||||
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
|
|
||||||
des éléments d'un même conteneur — voir reorder_element."""
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
target_id = int(data["target_id"])
|
|
||||||
position = data.get("position")
|
|
||||||
if position not in ("before", "after"):
|
|
||||||
abort(400)
|
|
||||||
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
|
|
||||||
return jsonify({"ok": True})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from flask import request, redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/save", methods=["POST"])
|
|
||||||
def element_save(slug, element_id):
|
|
||||||
"""Un seul formulaire pour tout le panneau de propriétés : un champ par
|
|
||||||
réglage du widget (texte, case à cocher, curseur, couleur...) — c'est
|
|
||||||
screens.save_element_controls qui sait vers quel attribut ou quelle
|
|
||||||
propriété CSS chaque réglage doit être traduit."""
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
screens.save_element_controls(slug, element_id, request.form)
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from flask import request, jsonify
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
from core.sprite_gate import forbid_admin_only_character
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
|
|
||||||
def element_set_personnage_data(slug, element_id):
|
|
||||||
"""Change le personnage Forge d'un élément déjà posé (galerie du
|
|
||||||
panneau de propriétés), ou met à jour ses animations personnalisées
|
|
||||||
(import de frames) — voir personnage-panel.js. Renvoie les animations
|
|
||||||
résolues pour que le client relance immédiatement l'aperçu animé sans
|
|
||||||
recharger la page (voir personnage-preview.js)."""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
el = screens.get_element(slug, element_id)
|
|
||||||
if not el:
|
|
||||||
return jsonify({"error": "élément introuvable"}), 404
|
|
||||||
if body.get("source") == "forge" and body.get("forge_character"):
|
|
||||||
forbid_admin_only_character(body["forge_character"])
|
|
||||||
screens.set_personnage_data(slug, element_id, body)
|
|
||||||
el = screens.get_element(slug, element_id)
|
|
||||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from flask import redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
|
|
||||||
def element_tab_add(slug, element_id):
|
|
||||||
onglets = screens.get_element(slug, element_id)
|
|
||||||
if not onglets:
|
|
||||||
abort(404)
|
|
||||||
screens.add_tab(slug, element_id, onglets["screen_id"])
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
|
|
||||||
@@ -8,25 +8,12 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>")
|
@app.route("/game/<slug>")
|
||||||
def game_dashboard(slug):
|
def game_dashboard(slug):
|
||||||
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
|
"""Vue d'ensemble du jeu : juste ses écrans, en cartes avec un aperçu
|
||||||
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
|
de leur contenu, plus un bouton "+ Ajouter un écran" qui ne demande pas
|
||||||
accès direct à chaque élément — la barre de navigation du jeu (voir
|
de type (un seul moteur d'écran : jeu_2d — voir
|
||||||
base.html) permet déjà de sauter directement vers chaque éditeur, cette
|
routes/screens/screens_new.py)."""
|
||||||
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."""
|
|
||||||
game = db.game_meta(slug)
|
game = db.game_meta(slug)
|
||||||
if db.is_restricted(slug):
|
|
||||||
return _render_simple_dashboard(slug, game)
|
return _render_simple_dashboard(slug, game)
|
||||||
return _render_full_dashboard(slug, game)
|
|
||||||
|
|
||||||
|
|
||||||
def _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"])
|
objects = screens.list_scene_objects(slug, s["id"])
|
||||||
for o in objects:
|
for o in objects:
|
||||||
o["rendered_html"] = screens.render_scene_object(o)
|
o["rendered_html"] = screens.render_scene_object(o)
|
||||||
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
|
# Taille du MONDE (voir screens.resolve_scene_world_size,
|
||||||
else:
|
# 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"]] = {
|
previews[s["id"]] = {
|
||||||
"kind": "document",
|
"kind": "jeu_2d", "objects": objects,
|
||||||
"elements": screens.list_elements(slug, s["id"]),
|
"world_width": world_width, "world_height": world_height,
|
||||||
}
|
}
|
||||||
return render_template(
|
return render_template(
|
||||||
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
|
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_full_dashboard(slug, game):
|
|
||||||
definitions = db.list_definitions(slug)
|
|
||||||
# Détail complet (avec ses champs) de chaque objet, pour le panneau
|
|
||||||
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
|
|
||||||
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
|
|
||||||
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
|
|
||||||
# l'ouverture (voir openObjectEditPanel()).
|
|
||||||
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
|
|
||||||
|
|
||||||
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
|
|
||||||
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
|
|
||||||
# objet, les libellés lisibles de ses champs relation (pour l'affichage
|
|
||||||
# du tableau) et les options possibles de ces mêmes champs relation
|
|
||||||
# (pour le formulaire "Ajouter une entrée").
|
|
||||||
rows_by_definition = {}
|
|
||||||
relation_labels_by_definition = {}
|
|
||||||
relation_options_by_definition = {}
|
|
||||||
referenced_by_definition = {}
|
|
||||||
for d in definitions:
|
|
||||||
full = definitions_full[d["id"]]
|
|
||||||
rows_by_definition[d["id"]] = db.list_rows(slug, full)
|
|
||||||
relation_labels = {}
|
|
||||||
relation_options = {}
|
|
||||||
for f in full["fields"]:
|
|
||||||
if f["type"] == "relation":
|
|
||||||
options = db.relation_options(slug, f["relation_definition_id"])
|
|
||||||
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
|
|
||||||
relation_options[f["id"]] = options
|
|
||||||
relation_labels_by_definition[d["id"]] = relation_labels
|
|
||||||
relation_options_by_definition[d["id"]] = relation_options
|
|
||||||
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
|
|
||||||
|
|
||||||
screen_list = screens.list_screens(slug)
|
|
||||||
element_types = screens.list_element_types(slug)
|
|
||||||
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
|
|
||||||
variables = db.list_global_variables(slug)
|
|
||||||
return render_template(
|
|
||||||
"game_dashboard.html", game=game, definitions=definitions,
|
|
||||||
definitions_full=definitions_full,
|
|
||||||
rows_by_definition=rows_by_definition,
|
|
||||||
relation_labels_by_definition=relation_labels_by_definition,
|
|
||||||
relation_options_by_definition=relation_options_by_definition,
|
|
||||||
referenced_by_definition=referenced_by_definition,
|
|
||||||
screen_list=screen_list, element_types=element_types,
|
|
||||||
element_type_usage=element_type_usage, variables=variables,
|
|
||||||
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from . import action_add, action_delete, action_run_data
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from flask import request, redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/elements/<int:element_id>/actions/add", methods=["POST"])
|
|
||||||
def action_add(slug, element_id):
|
|
||||||
element = screens.get_element(slug, element_id)
|
|
||||||
if not element:
|
|
||||||
abort(404)
|
|
||||||
trigger_event = screens._trigger_for(element["widget"], element["attributes"])
|
|
||||||
action_type = request.form.get("action_type", "rien")
|
|
||||||
|
|
||||||
def _int_or_none(key):
|
|
||||||
raw = request.form.get(key)
|
|
||||||
try:
|
|
||||||
return int(raw) if raw not in (None, "") else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
screens.add_action(
|
|
||||||
slug, element_id, trigger_event, action_type,
|
|
||||||
target_screen_id=_int_or_none("target_screen_id"),
|
|
||||||
target_element_id=_int_or_none("target_element_id"),
|
|
||||||
element_property=request.form.get("element_property") or None,
|
|
||||||
element_value=request.form.get("element_value") or None,
|
|
||||||
target_definition_id=_int_or_none("target_definition_id"),
|
|
||||||
target_row_id=_int_or_none("target_row_id"),
|
|
||||||
target_field=request.form.get("target_field") or None,
|
|
||||||
data_operation=request.form.get("data_operation") or None,
|
|
||||||
data_value=request.form.get("data_value") or None,
|
|
||||||
)
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from flask import redirect, url_for, abort
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/actions/<int:action_id>/delete", methods=["POST"])
|
|
||||||
def action_delete(slug, action_id):
|
|
||||||
action = screens.get_action(slug, action_id)
|
|
||||||
if not action:
|
|
||||||
abort(404)
|
|
||||||
element = screens.get_element(slug, action["element_id"])
|
|
||||||
screens.delete_action(slug, action_id)
|
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element["id"]))
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from flask import jsonify
|
|
||||||
|
|
||||||
import screens
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/actions/<int:action_id>/run-data", methods=["POST"])
|
|
||||||
def action_run_data(slug, action_id):
|
|
||||||
"""Exécuté côté serveur au moment du clic, en mode jouable, pour une
|
|
||||||
action "Modifier une donnée d'un objet" — les actions "Modifier un
|
|
||||||
élément" restent, elles, purement côté client (juste du style CSS)."""
|
|
||||||
action = screens.get_action(slug, action_id)
|
|
||||||
if not action or action.get("action_type") != "modifier_donnee":
|
|
||||||
return jsonify({"ok": False}), 400
|
|
||||||
ok = screens.apply_data_action(slug, action)
|
|
||||||
return jsonify({"ok": ok})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from . import (
|
|
||||||
parse_field_rows,
|
|
||||||
object_new,
|
|
||||||
object_view,
|
|
||||||
object_edit,
|
|
||||||
object_field_add,
|
|
||||||
object_field_edit,
|
|
||||||
object_field_delete,
|
|
||||||
object_delete,
|
|
||||||
data_form,
|
|
||||||
data_new,
|
|
||||||
data_edit,
|
|
||||||
data_delete,
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from flask import redirect, url_for, abort
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/delete", methods=["POST"])
|
|
||||||
def data_delete(slug, definition_id, row_id):
|
|
||||||
definition = db.get_definition(slug, definition_id)
|
|
||||||
if not definition:
|
|
||||||
abort(404)
|
|
||||||
referencing = db.rows_referencing(slug, definition_id, row_id)
|
|
||||||
if referencing:
|
|
||||||
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
|
|
||||||
# une relation. On revient au panneau de cet objet (onglet
|
|
||||||
# Données), qui explique pourquoi via un paramètre de requête
|
|
||||||
# simple plutôt qu'une session/flash message.
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
|
|
||||||
db.delete_row(slug, definition, row_id)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from flask import abort
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
from .data_form import _data_form
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/edit", methods=["GET", "POST"])
|
|
||||||
def data_edit(slug, definition_id, row_id):
|
|
||||||
definition = db.get_definition(slug, definition_id)
|
|
||||||
if not definition:
|
|
||||||
abort(404)
|
|
||||||
row = db.get_row(slug, definition, row_id)
|
|
||||||
if not row:
|
|
||||||
abort(404)
|
|
||||||
return _data_form(slug, definition_id, row=row)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from flask import request, redirect, url_for, abort
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
|
|
||||||
def _data_form(slug, definition_id, row):
|
|
||||||
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
|
|
||||||
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
|
|
||||||
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
|
|
||||||
(data_form.html, retirée) — cette fonction ne fait donc plus que
|
|
||||||
traiter la soumission de ce formulaire. Un GET direct redirige vers le
|
|
||||||
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
|
|
||||||
Données."""
|
|
||||||
definition = db.get_definition(slug, definition_id)
|
|
||||||
if not definition:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
if row is None:
|
|
||||||
db.insert_row(slug, definition, request.form)
|
|
||||||
else:
|
|
||||||
db.update_row(slug, definition, row["id"], request.form)
|
|
||||||
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from core.flask_app import app
|
|
||||||
from .data_form import _data_form
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/data/new", methods=["GET", "POST"])
|
|
||||||
def data_new(slug, definition_id):
|
|
||||||
return _data_form(slug, definition_id, row=None)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from flask import redirect, url_for
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/delete", methods=["POST"])
|
|
||||||
def object_delete(slug, definition_id):
|
|
||||||
referenced_by = db.definitions_referencing(slug, definition_id)
|
|
||||||
if referenced_by:
|
|
||||||
# Suppression bloquée : d'autres objets ont une relation vers
|
|
||||||
# celui-ci. On revient à son panneau, qui affiche déjà cet
|
|
||||||
# avertissement (referenced_by_definition, voir game_dashboard.py)
|
|
||||||
# en permanence, pas seulement après une tentative — plutôt que de
|
|
||||||
# casser ces relations silencieusement.
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
|
||||||
db.delete_definition(slug, definition_id)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from flask import request, redirect, url_for, abort
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
|
|
||||||
def object_edit(slug, definition_id):
|
|
||||||
"""Renommer un objet + gérer ses champs se fait désormais depuis un
|
|
||||||
panneau déplaçable/redimensionnable sur le tableau de bord (voir
|
|
||||||
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
|
|
||||||
qu'une page dédiée — cette route ne fait donc plus que traiter le
|
|
||||||
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
|
|
||||||
le panneau de cet objet (?edit=<id>)."""
|
|
||||||
if request.method == "POST":
|
|
||||||
definition = db.get_definition(slug, definition_id)
|
|
||||||
if not definition:
|
|
||||||
abort(404)
|
|
||||||
new_name = request.form.get("object_name", "").strip()
|
|
||||||
if new_name and new_name != definition["name"]:
|
|
||||||
db.rename_definition(slug, definition_id, new_name)
|
|
||||||
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/add", methods=["POST"])
|
|
||||||
def object_field_add(slug, definition_id):
|
|
||||||
name = request.form.get("field_name", "").strip()
|
|
||||||
field_type = request.form.get("field_type", "texte")
|
|
||||||
relation_definition_id = request.form.get("field_relation")
|
|
||||||
# Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet
|
|
||||||
# cible valide ferait planter add_field_to_definition (int("") lève),
|
|
||||||
# ce qui annulait l'ALTER TABLE avant son commit() -- le champ
|
|
||||||
# n'était alors ajouté du tout, sans le moindre message d'erreur.
|
|
||||||
if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()):
|
|
||||||
db.add_field_to_definition(slug, definition_id, {
|
|
||||||
"name": name,
|
|
||||||
"type": field_type,
|
|
||||||
"relation_definition_id": relation_definition_id,
|
|
||||||
"required": request.form.get("field_required") == "1",
|
|
||||||
"min_value": request.form.get("field_min") or None,
|
|
||||||
"max_value": request.form.get("field_max") or None,
|
|
||||||
})
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from flask import redirect, url_for
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
|
|
||||||
def object_field_delete(slug, definition_id, field_id):
|
|
||||||
db.delete_field(slug, definition_id, field_id)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/edit", methods=["POST"])
|
|
||||||
def object_field_edit(slug, definition_id, field_id):
|
|
||||||
new_name = request.form.get("field_name", "").strip()
|
|
||||||
if new_name:
|
|
||||||
db.update_field(
|
|
||||||
slug, definition_id, field_id,
|
|
||||||
new_name=new_name,
|
|
||||||
required=request.form.get("field_required") == "1",
|
|
||||||
relation_definition_id=request.form.get("field_relation"),
|
|
||||||
min_value=request.form.get("field_min") or None,
|
|
||||||
max_value=request.form.get("field_max") or None,
|
|
||||||
)
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
from .parse_field_rows import _parse_field_rows
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/new", methods=["GET", "POST"])
|
|
||||||
def object_new(slug):
|
|
||||||
"""La création d'un objet se fait désormais depuis un panneau déplaçable
|
|
||||||
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
|
|
||||||
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
|
|
||||||
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
|
|
||||||
externe obsolète) redirige simplement vers le tableau de bord."""
|
|
||||||
if request.method == "POST":
|
|
||||||
name = request.form.get("object_name", "").strip()
|
|
||||||
# Aucun champ requis à la création : create_definition(fields=[])
|
|
||||||
# crée simplement une table sans colonne "métier" (juste id/
|
|
||||||
# created_at), et le panneau "Modifier un objet" (onglet Champs,
|
|
||||||
# bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins
|
|
||||||
# un champ ici faisait échouer silencieusement la création (aucun
|
|
||||||
# message d'erreur, juste un retour au tableau de bord) dès qu'on
|
|
||||||
# tapait le nom sans avoir encore cliqué "+ Ajouter un champ".
|
|
||||||
fields = _parse_field_rows(request.form)
|
|
||||||
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
|
|
||||||
# explicitement rend l'objet partagé par tous les joueurs — ex. un
|
|
||||||
# catalogue/une liste de questions — voir db/definitions/
|
|
||||||
# create_definition.py).
|
|
||||||
per_player = request.form.get("per_player", "1") == "1"
|
|
||||||
if name:
|
|
||||||
definition_id = db.create_definition(slug, name, fields, per_player=per_player)
|
|
||||||
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
|
||||||
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug))
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from flask import redirect, url_for
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/objects/<int:definition_id>")
|
|
||||||
def object_view(slug, definition_id):
|
|
||||||
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
|
|
||||||
retirée : les données vivent désormais dans l'onglet "Données" du
|
|
||||||
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
|
|
||||||
route reste néanmoins en place, comme pur redirecteur, pour deux
|
|
||||||
raisons : un lien externe/ancien vers cette URL continue de mener
|
|
||||||
quelque part de sensé, et surtout beaucoup de tests (et certains
|
|
||||||
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
|
|
||||||
segment du chemin renvoyé par le header Location d'une redirection
|
|
||||||
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
|
|
||||||
termine par l'id), qui redirige ensuite vers le tableau de bord."""
|
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
def _parse_field_rows(form):
|
|
||||||
field_names = form.getlist("field_name[]")
|
|
||||||
field_types = form.getlist("field_type[]")
|
|
||||||
field_relations = form.getlist("field_relation[]")
|
|
||||||
field_required = form.getlist("field_required[]")
|
|
||||||
field_min = form.getlist("field_min[]")
|
|
||||||
field_max = form.getlist("field_max[]")
|
|
||||||
fields = []
|
|
||||||
for i, fname in enumerate(field_names):
|
|
||||||
if not fname.strip():
|
|
||||||
continue
|
|
||||||
ftype = field_types[i]
|
|
||||||
relation_definition_id = field_relations[i] if i < len(field_relations) else None
|
|
||||||
if ftype == "relation" and not (relation_definition_id or "").strip().isdigit():
|
|
||||||
# Type "Relation vers un autre objet" choisi sans (ou avant
|
|
||||||
# d'avoir pu) sélectionner d'objet cible réel — ex. le tout
|
|
||||||
# premier objet créé dans un jeu, quand ce sélecteur est encore
|
|
||||||
# vide faute d'un autre objet à pointer. create_definition()
|
|
||||||
# fait un int(relation_definition_id) sans filet : le laisser
|
|
||||||
# passer plantait TOUTE la requête (500), et comme la ligne
|
|
||||||
# _definitions n'était alors jamais validée (pas de commit()
|
|
||||||
# atteint), l'objet n'était pas créé du tout — pas de champ
|
|
||||||
# "Relation" non plus, mais un panneau qui semble "ne rien
|
|
||||||
# faire" plutôt qu'un message d'erreur clair. On ignore donc ce
|
|
||||||
# champ précis (comme une ligne sans nom, ci-dessus) plutôt que
|
|
||||||
# de faire échouer la création de TOUT l'objet à cause de lui.
|
|
||||||
continue
|
|
||||||
fields.append({
|
|
||||||
"name": fname.strip(),
|
|
||||||
"type": ftype,
|
|
||||||
"relation_definition_id": relation_definition_id,
|
|
||||||
"required": field_required[i] == "1" if i < len(field_required) else False,
|
|
||||||
"min_value": (field_min[i].strip() or None) if i < len(field_min) else None,
|
|
||||||
"max_value": (field_max[i].strip() or None) if i < len(field_max) else None,
|
|
||||||
})
|
|
||||||
return fields
|
|
||||||
@@ -9,9 +9,26 @@ from core.flask_app import app
|
|||||||
def quest_dialogues(slug, quest_id):
|
def quest_dialogues(slug, quest_id):
|
||||||
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
|
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
|
||||||
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
|
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
|
||||||
complet à chaque modification plutôt qu'une réplique isolée."""
|
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"))
|
Refusé (400) si la somme des récompenses des questions ("❓ Question")
|
||||||
if dialogues is None:
|
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
|
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})
|
return jsonify({"dialogues": dialogues})
|
||||||
|
|||||||
@@ -7,7 +7,29 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
|
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
|
||||||
def quest_update(slug, quest_id):
|
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 {}
|
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)
|
quest = db.update_quest(slug, quest_id, body)
|
||||||
if quest is None:
|
if quest is None:
|
||||||
return jsonify({"error": "quête introuvable"}), 404
|
return jsonify({"error": "quête introuvable"}), 404
|
||||||
|
|||||||
@@ -7,4 +7,8 @@ from . import (
|
|||||||
scene_object_role,
|
scene_object_role,
|
||||||
scene_object_collision,
|
scene_object_collision,
|
||||||
scene_object_collision_rules,
|
scene_object_collision_rules,
|
||||||
|
scene_object_name,
|
||||||
|
scene_object_dialogue_box_style,
|
||||||
|
scene_collision_rule_objects,
|
||||||
|
scene_object_names_list,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import screens
|
||||||
|
|
||||||
|
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||||
|
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
|
||||||
|
|
||||||
|
|
||||||
|
def build_collision_rule_objects(objects):
|
||||||
|
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
|
||||||
|
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
|
||||||
|
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
||||||
|
screens/rendering/collision_rules.py) — factorisé ici plutôt que
|
||||||
|
dupliqué : utilisé au premier rendu de la page (routes/scenes/
|
||||||
|
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
|
||||||
|
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
|
||||||
|
scene-editor.js) pour reconstruire la liste SANS recharger la page.
|
||||||
|
|
||||||
|
`objects` doit déjà porter "rendered_html" (voir
|
||||||
|
screens.render_scene_object) sur chaque élément.
|
||||||
|
|
||||||
|
resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans
|
||||||
|
rôle explicitement enregistré (voir screens/rendering/
|
||||||
|
personnage_role.py — comportement voulu pour une scène à un seul
|
||||||
|
personnage, posé avant l'ajout de ce réglage). Appliqué tel quel,
|
||||||
|
ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite
|
||||||
|
(le cas de toute scène créée avant ce réglage — un personnage ajouté
|
||||||
|
depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite,
|
||||||
|
voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et
|
||||||
|
disparaîtraient donc TOUS de cette liste. On ne considère donc comme
|
||||||
|
"joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le
|
||||||
|
plus ancien, z_index croissant comme `objects`) — les suivants sans
|
||||||
|
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
|
||||||
|
base."""
|
||||||
|
implicit_joueur_claimed = any(
|
||||||
|
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
|
||||||
|
for o in objects if o["kind"] == "personnage"
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for o in objects:
|
||||||
|
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
|
||||||
|
continue
|
||||||
|
role = None
|
||||||
|
if o["kind"] == "personnage":
|
||||||
|
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
|
||||||
|
if explicit_role in screens.PERSONNAGE_ROLES:
|
||||||
|
role = explicit_role
|
||||||
|
elif not implicit_joueur_claimed:
|
||||||
|
role = "joueur"
|
||||||
|
implicit_joueur_claimed = True
|
||||||
|
else:
|
||||||
|
role = "pnj"
|
||||||
|
if role == "joueur":
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"id": o["id"],
|
||||||
|
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
|
||||||
|
"kind": o["kind"],
|
||||||
|
"role": role,
|
||||||
|
"thumbnail_html": o["rendered_html"],
|
||||||
|
"rules": screens.resolve_collision_rules(o),
|
||||||
|
})
|
||||||
|
return result
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
from .collision_rule_objects import build_collision_rule_objects
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/collision-rule-objects", methods=["GET"])
|
||||||
|
def scene_collision_rule_objects(slug, screen_id):
|
||||||
|
"""Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS,
|
||||||
|
templates/scene_edit.html) — appelée en AJAX après un ajout/suppression
|
||||||
|
d'objet, ou un changement de rôle/nom (voir static/js/scenes/
|
||||||
|
scene-editor.js) qui peut faire apparaître/disparaître un objet de
|
||||||
|
cette liste (rôle passé à/depuis "joueur", voir
|
||||||
|
build_collision_rule_objects) ou changer son libellé affiché (nom).
|
||||||
|
Recalculée entièrement plutôt que diffée : la liste reste petite (un
|
||||||
|
objet de scène par carte, jamais un "fond"/widget d'UI) et cette même
|
||||||
|
fonction est déjà appelée à chaque rendu complet de la page."""
|
||||||
|
objects = screens.list_scene_objects(slug, screen_id)
|
||||||
|
for o in objects:
|
||||||
|
o["rendered_html"] = screens.render_scene_object(o)
|
||||||
|
return jsonify(build_collision_rule_objects(objects))
|
||||||
@@ -5,6 +5,8 @@ from flask import render_template, request, abort, g
|
|||||||
import db
|
import db
|
||||||
import screens
|
import screens
|
||||||
|
|
||||||
|
from .collision_rule_objects import build_collision_rule_objects
|
||||||
|
|
||||||
|
|
||||||
def render_scene_edit(slug, screen_id, game):
|
def render_scene_edit(slug, screen_id, game):
|
||||||
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
"""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)
|
objects = screens.list_scene_objects(slug, screen_id)
|
||||||
for o in objects:
|
for o in objects:
|
||||||
o["rendered_html"] = screens.render_scene_object(o)
|
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)
|
selected_id = request.args.get("selected", type=int)
|
||||||
personnage_data = None
|
personnage_data = None
|
||||||
personnage_commands = None
|
personnage_commands = None
|
||||||
personnage_role = None
|
personnage_role = None
|
||||||
collision_settings = 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
|
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:
|
if selected_obj:
|
||||||
|
if selected_obj["kind"] not in _UI_WIDGET_KINDS:
|
||||||
collision_settings = screens.resolve_collision_settings(selected_obj)
|
collision_settings = screens.resolve_collision_settings(selected_obj)
|
||||||
if selected_obj["kind"] == "personnage":
|
if selected_obj["kind"] == "personnage":
|
||||||
personnage_data = screens.resolve_personnage_data(selected_obj)
|
personnage_data = screens.resolve_personnage_data(selected_obj)
|
||||||
personnage_commands = screens.resolve_personnage_commands(selected_obj)
|
personnage_commands = screens.resolve_personnage_commands(selected_obj)
|
||||||
personnage_role = screens.resolve_personnage_role(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"}
|
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||||
object_labels = {
|
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"
|
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
|
# "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/
|
||||||
# ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
|
# collision_rule_objects.py, factorisé pour être réutilisé par
|
||||||
# TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
|
# l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/
|
||||||
# screens/rendering/collision_rules.py) — construit ici plutôt que
|
# suppression/changement de rôle ou de nom sans recharger la page).
|
||||||
# réutiliser `objects` tel quel, pour ne poser resolve_personnage_role
|
collision_rule_objects = build_collision_rule_objects(objects)
|
||||||
# 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),
|
|
||||||
})
|
|
||||||
|
|
||||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||||
flow_nodes = screens.list_flow_nodes(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(
|
return render_template(
|
||||||
"scene_edit.html",
|
"scene_edit.html",
|
||||||
game=game, screen=screen, all_screens=all_screens,
|
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,
|
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
|
||||||
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
|
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
|
||||||
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
|
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
|
||||||
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
|
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
|
||||||
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
|
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=sprite_library, sprite_library_families=sprite_library_families,
|
||||||
sprite_library_json=json.dumps(sprite_library),
|
sprite_library_json=json.dumps(sprite_library),
|
||||||
background_library=background_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"
|
# existante, sans attendre l'ouverture de l'assistant "+ Action"
|
||||||
# (seul moment où questPickerHtml irait la chercher sinon).
|
# (seul moment où questPickerHtml irait la chercher sinon).
|
||||||
quests_json=json.dumps(db.list_quests(slug)),
|
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)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import request, redirect, url_for
|
from flask import request, redirect, url_for, jsonify
|
||||||
|
|
||||||
import screens
|
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):
|
def scene_object_add(slug, screen_id):
|
||||||
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
|
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
|
||||||
element_add.py, mais pour _scene_objects (voir screens/scenes/
|
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")
|
kind = request.form.get("kind", "personnage")
|
||||||
forge_character = request.form.get("forge_character")
|
forge_character = request.form.get("forge_character")
|
||||||
background_slug = request.form.get("background_slug")
|
background_slug = request.form.get("background_slug")
|
||||||
@@ -21,7 +34,22 @@ def scene_object_add(slug, screen_id):
|
|||||||
new_id = screens.add_scene_object(
|
new_id = screens.add_scene_object(
|
||||||
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
|
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,
|
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
|
||||||
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
||||||
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
|
# 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))
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import redirect, url_for
|
from flask import redirect, request, url_for, jsonify
|
||||||
|
|
||||||
import screens
|
import screens
|
||||||
|
|
||||||
@@ -7,9 +7,24 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
|
||||||
def scene_object_delete(slug, object_id):
|
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)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
if not obj:
|
if not obj:
|
||||||
|
if request.headers.get("X-Requested-With") == "fetch":
|
||||||
|
return jsonify({"ok": False}), 404
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
screen_id = obj["scene_id"]
|
screen_id = obj["scene_id"]
|
||||||
screens.delete_scene_object(slug, object_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))
|
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})
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-object-names", methods=["GET"])
|
||||||
|
def scene_object_names_list(slug):
|
||||||
|
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
|
||||||
|
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
|
||||||
|
templates/scene_edit.html, questSpeakerOptions() dans static/js/
|
||||||
|
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
|
||||||
|
chargement complet de la page (HORS de #builder3, jamais rafraîchie
|
||||||
|
par une sélection d'objet sans rechargement, voir goToSelected()) :
|
||||||
|
sans cet endpoint, renommer un personnage puis rouvrir la modale de
|
||||||
|
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
|
||||||
|
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
|
||||||
|
temps — appelée en AJAX depuis saveSceneObjectName()."""
|
||||||
|
return jsonify(screens.list_named_scene_object_names(slug))
|
||||||
@@ -1 +1 @@
|
|||||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music
|
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_background_music, screen_set_scene_size
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import json
|
from flask import abort
|
||||||
|
|
||||||
from flask import render_template, request, abort, g
|
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import screens
|
import screens
|
||||||
@@ -15,124 +13,6 @@ def screen_edit(slug, screen_id):
|
|||||||
screen = screens.get_screen(slug, screen_id)
|
screen = screens.get_screen(slug, screen_id)
|
||||||
if not screen:
|
if not screen:
|
||||||
abort(404)
|
abort(404)
|
||||||
if screen["kind"] == "jeu_2d":
|
# Un seul moteur d'écran désormais (jeu_2d) — voir
|
||||||
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
|
# 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)
|
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),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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})
|
|
||||||
@@ -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})
|
||||||
@@ -9,18 +9,9 @@ from core.flask_app import app
|
|||||||
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
||||||
def screens_new(slug):
|
def screens_new(slug):
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
if db.is_restricted(slug):
|
# Un seul moteur d'écran désormais (jeu_2d) — imposé à tout écran créé,
|
||||||
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
|
# quel que soit le projet (voir db/games/game_type_catalog.py).
|
||||||
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
|
kind = "jeu_2d"
|
||||||
# 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"
|
|
||||||
if name:
|
if name:
|
||||||
screen_id = screens.create_screen(slug, name, kind=kind)
|
screen_id = screens.create_screen(slug, name, kind=kind)
|
||||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||||
|
|||||||
+15
-67
@@ -1,34 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
screens.py — écrans de jeu : éléments posés en glisser-déposer sur un
|
screens.py — écrans de jeu : un seul moteur d'écran, la scène 2D "jeu_2d"
|
||||||
écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans,
|
(voir screens/scenes/) — objets posés en pixels, enchaînement des écrans,
|
||||||
et actions déclenchées par clic ou soumission de formulaire.
|
et logique déclenchée par clic/collision/clavier/minuteur (voir
|
||||||
|
screens/flow/).
|
||||||
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.
|
|
||||||
|
|
||||||
Tout est stocké dans la même base SQLite que le reste du jeu
|
Tout est stocké dans la même base SQLite que le reste du jeu
|
||||||
(projects/<slug>/game.db) :
|
(projects/<slug>/game.db) :
|
||||||
- _screens : un écran du jeu (accueil, formulaire, scène...)
|
- _screens : un écran du jeu (une scène...)
|
||||||
- _element_types : le "catalogue" des éléments de jeu réutilisables
|
- _scene_objects : chaque objet posé sur une scène (personnage,
|
||||||
(Personnage, Outil, IA...) que l'utilisateur crée
|
décor, fond, widgets d'interface...)
|
||||||
- _screen_elements : chaque élément posé sur un écran (widget, position
|
- _flow_nodes/_flow_edges : le graphe de logique de chaque écran
|
||||||
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}}.
|
|
||||||
|
|
||||||
Ce fichier est désormais un package (voir les sous-dossiers) organisé
|
Ce fichier est désormais un package (voir les sous-dossiers) organisé
|
||||||
selon "un fichier = une fonction, un dossier = une responsabilité" ; ce
|
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.
|
de fonctionner à l'identique.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .widgets.registry import WIDGETS
|
from .data_definitions.data_definition_options import data_definition_options
|
||||||
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 .labels.trigger_labels import TRIGGER_LABELS
|
|
||||||
from .labels.action_type_labels import ACTION_TYPE_LABELS
|
from .labels.action_type_labels import ACTION_TYPE_LABELS
|
||||||
from .labels.element_action_properties import (
|
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,
|
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
|
||||||
)
|
)
|
||||||
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_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,
|
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.trigger_for import _trigger_for
|
||||||
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
|
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
|
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,
|
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
|
||||||
)
|
)
|
||||||
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
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.ensure_schema import ensure_schema
|
||||||
from .screens_repo.list_screens import list_screens
|
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.delete_screen import delete_screen
|
||||||
from .screens_repo.move_screen import move_screen
|
from .screens_repo.move_screen import move_screen
|
||||||
from .screens_repo.screens_referencing import screens_referencing
|
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 .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.ensure_scene_schema import ensure_scene_schema
|
||||||
from .scenes.add_scene_object import add_scene_object
|
from .scenes.add_scene_object import add_scene_object
|
||||||
from .scenes.list_scene_objects import list_scene_objects
|
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.get_scene_object import get_scene_object
|
||||||
from .scenes.update_scene_object_geometry import update_scene_object_geometry
|
from .scenes.update_scene_object_geometry import update_scene_object_geometry
|
||||||
from .scenes.delete_scene_object import delete_scene_object
|
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_role import set_scene_object_role
|
||||||
from .scenes.set_scene_object_collision import set_scene_object_collision
|
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_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.render_scene_object import render_scene_object
|
||||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
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.ensure_flow_schema import ensure_flow_schema
|
||||||
from .flow.list_flow_nodes import list_flow_nodes
|
from .flow.list_flow_nodes import list_flow_nodes
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""Éléments posés sur un écran (_screen_elements)."""
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -11,7 +11,7 @@ def ensure_flow_schema(slug):
|
|||||||
node_type TEXT NOT NULL,
|
node_type TEXT NOT NULL,
|
||||||
x REAL NOT NULL DEFAULT 40,
|
x REAL NOT NULL DEFAULT 40,
|
||||||
y 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,
|
trigger_event TEXT,
|
||||||
cond_definition_id INTEGER,
|
cond_definition_id INTEGER,
|
||||||
cond_row_id INTEGER,
|
cond_row_id INTEGER,
|
||||||
@@ -21,7 +21,7 @@ def ensure_flow_schema(slug):
|
|||||||
cond_value TEXT,
|
cond_value TEXT,
|
||||||
action_type TEXT,
|
action_type TEXT,
|
||||||
target_screen_id INTEGER REFERENCES _screens(id),
|
target_screen_id INTEGER REFERENCES _screens(id),
|
||||||
target_element_id INTEGER REFERENCES _screen_elements(id),
|
target_element_id INTEGER,
|
||||||
element_property TEXT,
|
element_property TEXT,
|
||||||
element_value TEXT,
|
element_value TEXT,
|
||||||
target_definition_id INTEGER,
|
target_definition_id INTEGER,
|
||||||
|
|||||||
@@ -1,66 +1,10 @@
|
|||||||
# ---------- Actions : modifier un élément ----------
|
# ---------- Actions : orientation d'un objet de scène (jeu_2d) ----------
|
||||||
|
|
||||||
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
|
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
|
||||||
# l'élément (utile pour un personnage qui se déplace au clavier, voir
|
# l'objet (utile pour un personnage qui se déplace au clavier, voir
|
||||||
# applyElementProperty() dans static/js/play/actions.js — retourne
|
# applyElementProperty() dans static/js/play/actions.js — retourne
|
||||||
# l'élément en miroir plutôt que de nécessiter une 2e feuille de
|
# l'élément en miroir plutôt que de nécessiter une 2e feuille de sprites
|
||||||
# sprites "vue de dos").
|
# "vue de dos"). Repris par OBJECT_ACTION_PROPERTIES (screens/scenes/
|
||||||
("orientation", "Orientation — sens du personnage"),
|
# flow_palette.py) et routes/scenes/scene_edit_view.py.
|
||||||
]
|
|
||||||
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)
|
|
||||||
|
|
||||||
ELEMENT_ORIENTATION_VALUES = [
|
ELEMENT_ORIENTATION_VALUES = [
|
||||||
("droite", "Faire face à droite"),
|
("droite", "Faire face à droite"),
|
||||||
|
|||||||
@@ -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/."""
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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]
|
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import db
|
import db
|
||||||
|
|
||||||
from ..screens_repo.list_screens import list_screens
|
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_nodes import list_flow_nodes
|
||||||
from ..flow.list_flow_edges import list_flow_edges
|
from ..flow.list_flow_edges import list_flow_edges
|
||||||
from ..animations.list_animation_clips import list_animation_clips
|
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_data import resolve_personnage_animations
|
||||||
from ..rendering.personnage_commands import resolve_personnage_commands
|
from ..rendering.personnage_commands import resolve_personnage_commands
|
||||||
from ..rendering.personnage_role import resolve_personnage_role
|
from ..rendering.personnage_role import resolve_personnage_role
|
||||||
from ..rendering.collision_settings import resolve_collision_settings
|
from ..rendering.collision_settings import resolve_collision_settings
|
||||||
from ..rendering.collision_rules import resolve_collision_rules
|
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.list_scene_objects import list_scene_objects
|
||||||
from ..scenes.render_scene_object import render_scene_object
|
from ..scenes.render_scene_object import render_scene_object
|
||||||
|
|
||||||
@@ -44,7 +42,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
flows = {}
|
flows = {}
|
||||||
animations = {}
|
animations = {}
|
||||||
for s in screens_:
|
for s in screens_:
|
||||||
if s["kind"] == "jeu_2d":
|
|
||||||
objects = list_scene_objects(slug, s["id"])
|
objects = list_scene_objects(slug, s["id"])
|
||||||
for o in objects:
|
for o in objects:
|
||||||
o["rendered_html"] = render_scene_object(o)
|
o["rendered_html"] = render_scene_object(o)
|
||||||
@@ -57,6 +54,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
if o["kind"] == "personnage":
|
if o["kind"] == "personnage":
|
||||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||||
o["personnage_role"] = resolve_personnage_role(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::
|
# Boîte de collision (voir static/js/play/conditions.js::
|
||||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||||
# seulement "personnage" — un décor/fond peut aussi servir
|
# seulement "personnage" — un décor/fond peut aussi servir
|
||||||
@@ -72,24 +76,9 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
||||||
o["collision_rules"] = resolve_collision_rules(o)
|
o["collision_rules"] = resolve_collision_rules(o)
|
||||||
payload_screens.append({**s, "elements": objects})
|
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})
|
|
||||||
|
|
||||||
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
||||||
# écrans, écrans-modèles compris (include_templates=True) — pas
|
# écrans. Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
|
||||||
# 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
|
|
||||||
# affichage de la page de jeu (donc toujours à partir du personnage
|
# 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/
|
# 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
|
# 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
|
# voir runActionNode()/applyAnimationClip() qui font cette résolution
|
||||||
# à l'exécution à partir de gameData.personnage_animations plutôt que
|
# à l'exécution à partir de gameData.personnage_animations plutôt que
|
||||||
# depuis un data_value/custom_keyframes qui contiendrait des frames
|
# depuis un data_value/custom_keyframes qui contiendrait des frames
|
||||||
# déjà résolues). include_templates=True : un personnage peut vivre
|
# déjà résolues).
|
||||||
# dans un écran-modèle d'élément de jeu réutilisable, comme les flows/
|
|
||||||
# animations ci-dessous.
|
|
||||||
personnage_animations = {}
|
personnage_animations = {}
|
||||||
for s in list_screens(slug, include_templates=True):
|
for s in screens_:
|
||||||
flows[str(s["id"])] = {
|
flows[str(s["id"])] = {
|
||||||
"nodes": list_flow_nodes(slug, s["id"]),
|
"nodes": list_flow_nodes(slug, s["id"]),
|
||||||
"edges": list_flow_edges(slug, s["id"]),
|
"edges": list_flow_edges(slug, s["id"]),
|
||||||
}
|
}
|
||||||
animations[str(s["id"])] = list_animation_clips(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"]):
|
for o in list_scene_objects(slug, s["id"]):
|
||||||
if o["kind"] == "personnage":
|
if o["kind"] == "personnage":
|
||||||
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
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)
|
|
||||||
|
|
||||||
definitions = db.list_definitions(slug)
|
definitions = db.list_definitions(slug)
|
||||||
data = {}
|
data = {}
|
||||||
@@ -156,30 +138,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
for f in full["fields"]
|
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
|
# Instantané des variables globales, pour qu'un nœud Condition puisse en
|
||||||
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
||||||
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
|
# {{$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.
|
# ci-dessus sont déjà indexés par id, jamais par nom.
|
||||||
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
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 {
|
return {
|
||||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
"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,
|
"variables": variables, "personnage_animations": personnage_animations,
|
||||||
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
"definition_names": definition_names,
|
||||||
|
"quests": quests,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
Reference in New Issue
Block a user