Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance, video, son, visibilite, indication, attendre comme actions, utilisables aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par Ruby (IA), avec blocs deplacables/supprimables dans une chaine. - Corrige plusieurs variantes du bug "impossible de poser un objet hors du champ de la camera" (troncature du chainage d'actions a 4 maillons, fond importe pose a 128x128 au lieu de sa taille reelle, decalage du fond au vrai glisser-depose, redimensionnement manuel jamais propage au monde). - Ajoute un vrai glisser-depose depuis la galerie vers la scene, la gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/ Videos, suppression, reclassement fond<->decor sans re-upload). - Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement, limite 5 min), avec validation de la duree reelle du fichier, et une replique audio optionnelle dans une bulle de dialogue. - Fixe la taille de pose d'un objet/decor importe a 200x200 avec une boite de collision de 150x150. - Filtre le selecteur de fichier des actions son/video par type reel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b07b231a61
commit
559331f9cf
@@ -55,7 +55,11 @@ from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES
|
||||
from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
|
||||
from .rendering.collision_rules import (
|
||||
resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules,
|
||||
remove_action_from_rules, move_action_in_rules, backfill_missing_leaf_ids,
|
||||
)
|
||||
from .rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers, TRIGGER_TYPES_SCREEN
|
||||
from .labels.surbrillance_values import SURBRILLANCE_VALUES, SURBRILLANCE_LABELS
|
||||
from .labels.video_modes import VIDEO_MODES, VIDEO_MODE_LABELS
|
||||
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
|
||||
@@ -70,6 +74,10 @@ from .screens_repo.move_screen import move_screen
|
||||
from .screens_repo.screens_referencing import screens_referencing
|
||||
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_triggers import set_screen_triggers
|
||||
from .screens_repo.append_action_to_screen_trigger import append_action_to_screen_trigger
|
||||
from .screens_repo.remove_action_from_screen_trigger import remove_action_from_screen_trigger
|
||||
from .screens_repo.move_action_in_screen_trigger import move_action_in_screen_trigger
|
||||
|
||||
from .scenes.ensure_scene_schema import ensure_scene_schema
|
||||
from .scenes.add_scene_object import add_scene_object
|
||||
@@ -88,6 +96,8 @@ from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogu
|
||||
from .scenes.render_scene_object import render_scene_object
|
||||
from .scenes.build_collision_rule_objects import build_collision_rule_objects
|
||||
from .scenes.append_action_to_trigger import append_action_to_trigger
|
||||
from .scenes.remove_action_from_trigger import remove_action_from_trigger
|
||||
from .scenes.move_action_in_trigger import move_action_in_trigger
|
||||
from .rendering.trigger_graph import (
|
||||
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
|
||||
list_completable_dialogue_ids,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Vocabulaire de l'action "surbrillance" (voir screens/rendering/
|
||||
# collision_rules.py) — MÊME vocabulaire que la propriété "surbrillance"
|
||||
# de "Modifier un élément" (voir static/js/play/actions.js::
|
||||
# applyElementProperty, scripts/build_demo_dialogues.py), réutilisée telle
|
||||
# quelle (classe CSS .forgeHighlight) plutôt que réinventée pour les objets
|
||||
# de scène.
|
||||
SURBRILLANCE_VALUES = [
|
||||
("on", "Activer la surbrillance"),
|
||||
("off", "Désactiver la surbrillance"),
|
||||
("toggle", "Basculer la surbrillance"),
|
||||
]
|
||||
SURBRILLANCE_LABELS = dict(SURBRILLANCE_VALUES)
|
||||
@@ -0,0 +1,9 @@
|
||||
# Mode d'affichage de l'action "video" (voir screens/rendering/
|
||||
# collision_rules.py) — "plein_ecran" couvre tout l'écran de jeu (usage
|
||||
# cinématique/narration), "incrustation" reste une petite fenêtre par
|
||||
# dessus la scène (le joueur voit encore le décor autour).
|
||||
VIDEO_MODES = [
|
||||
("plein_ecran", "Plein écran"),
|
||||
("incrustation", "Incrustation (petite fenêtre)"),
|
||||
]
|
||||
VIDEO_MODE_LABELS = dict(VIDEO_MODES)
|
||||
@@ -9,6 +9,7 @@ from ..rendering.personnage_commands import resolve_personnage_commands
|
||||
from ..rendering.personnage_role import resolve_personnage_role
|
||||
from ..rendering.collision_settings import resolve_collision_settings
|
||||
from ..rendering.collision_rules import resolve_collision_rules
|
||||
from ..rendering.screen_triggers import resolve_screen_triggers
|
||||
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||
from ..rendering.trigger_graph import list_completable_dialogue_ids
|
||||
from ..scenes.list_scene_objects import list_scene_objects
|
||||
@@ -76,6 +77,12 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# l'inverse).
|
||||
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
|
||||
o["collision_rules"] = resolve_collision_rules(o)
|
||||
# Déclencheurs D'ÉCRAN (voir "Déclencheurs de l'écran", sans objet
|
||||
# requis — narration/cinématique dès l'affichage) : résolus ici
|
||||
# comme collision_rules ci-dessus, sous un nom distinct de la
|
||||
# colonne brute _screen_triggers (laissée telle quelle par ailleurs
|
||||
# dans `s`, comme le reste des colonnes _screens).
|
||||
s["screen_triggers"] = resolve_screen_triggers(s)
|
||||
payload_screens.append({**s, "elements": objects})
|
||||
|
||||
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from db.dialogue_lines import sanitize_dialogue_lines
|
||||
from screens.flow.constants import CONDITION_OPERATORS
|
||||
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS
|
||||
from screens.labels.surbrillance_values import SURBRILLANCE_LABELS
|
||||
from screens.labels.video_modes import VIDEO_MODE_LABELS
|
||||
|
||||
# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js::
|
||||
# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque
|
||||
@@ -10,10 +14,17 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
# personnage_role.py — et un "fond", jamais une cible/source de collision)
|
||||
# porte une LISTE de règles "déclencheur -> action".
|
||||
#
|
||||
# Déclencheur : "collision" (chevauchement direct, voir
|
||||
# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
|
||||
# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
|
||||
# demande explicite).
|
||||
# Déclencheur :
|
||||
# - "collision" : chevauchement direct avec le joueur (voir
|
||||
# static/js/play/conditions.js::forgeShapesOverlap) —
|
||||
# nécessite un personnage "joueur" sur l'écran
|
||||
# ("perimetre" retiré, ne servait à rien pour l'instant,
|
||||
# demande explicite).
|
||||
# - "clic" : l'objet est cliqué/touché — pas besoin de joueur, écouteur
|
||||
# posé directement sur l'objet (voir static/js/play/
|
||||
# collision-rules-controller.js::forgeBindClicSurvolRules).
|
||||
# - "survol" : le pointeur survole l'objet (souris) — même remarque
|
||||
# que "clic".
|
||||
#
|
||||
# Action :
|
||||
# - "dialogue" : la logique n'est plus portée par une "quête" mais
|
||||
@@ -53,12 +64,43 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
# screens/flow/constants.py::CONDITION_OPERATORS, même
|
||||
# vocabulaire que le nœud Condition de l'éditeur de
|
||||
# logique) -> exécute `si_vrai` ou `si_faux` (chacun soit
|
||||
# None — "ne rien faire", soit une FEUILLE — "dialogue"
|
||||
# ou "variable", voir maquette validée, JAMAIS une
|
||||
# nouvelle "interagir"/"condition" imbriquée : une
|
||||
# branche reste une feuille).
|
||||
# None — "ne rien faire", soit une FEUILLE (voir
|
||||
# LEAF_ACTION_TYPES ci-dessous), voir maquette validée,
|
||||
# JAMAIS une nouvelle "interagir"/"condition" imbriquée :
|
||||
# une branche reste une feuille).
|
||||
# - "surbrillance" : met un objet en surbrillance (n'importe lequel de
|
||||
# l'écran, pas forcément celui qui porte la règle — voir
|
||||
# `object_id`). MÊME vocabulaire ("on"/"off"/"toggle",
|
||||
# classe CSS .forgeHighlight) que la propriété
|
||||
# "surbrillance" de "Modifier un élément" (voir
|
||||
# static/js/play/actions.js::applyElementProperty,
|
||||
# screens/labels/surbrillance_values.py), réutilisée telle
|
||||
# quelle plutôt que réinventée.
|
||||
# - "visibilite" : rend un objet visible/masqué/invisible (voir
|
||||
# `object_id`, screens/labels/element_visibility.py::
|
||||
# ELEMENT_VISIBILITY_VALUES, même vocabulaire que
|
||||
# "Modifier un élément" côté Flow).
|
||||
# - "son" : joue un effet sonore ponctuel, non bouclé (voir
|
||||
# `asset_url`) — même comportement que l'action "Jouer un
|
||||
# son" du Flow (static/js/play/actions.js, `new
|
||||
# Audio(url).play()`), indépendant de la musique de fond.
|
||||
# - "video" : joue une vidéo uploadée dans "Mes assets" (voir
|
||||
# `asset_url`, `mode` ∈ VIDEO_MODES) — bloque la suite de
|
||||
# la chaîne "then" jusqu'à la fin de la vidéo (ou "passer"),
|
||||
# comme un dialogue.
|
||||
# - "indication": affiche une bulle de texte courte près d'un objet (voir
|
||||
# `object_id`, `texte`, `duree_ms` optionnel — disparaît
|
||||
# automatiquement après ce délai, par défaut voir
|
||||
# static/js/play/collision-rules-controller.js) — même
|
||||
# esprit que la bulle "Appuie sur [touche]" déjà utilisée
|
||||
# par "interagir".
|
||||
# - "attendre" : suspend la chaîne "then" pendant `data_value` SECONDES
|
||||
# (même vocabulaire que l'action "attendre" du Flow, voir
|
||||
# static/js/play/actions.js) avant de continuer — utile
|
||||
# par ex. pour laisser un temps de lecture après une
|
||||
# narration à l'affichage de l'écran.
|
||||
#
|
||||
# CHAÎNAGE : une feuille ("dialogue" ou "variable") peut porter un champ
|
||||
# CHAÎNAGE : une FEUILLE (voir LEAF_ACTION_TYPES) peut porter un champ
|
||||
# optionnel "then" — l'action suivante à exécuter une fois celle-ci
|
||||
# terminée, elle-même une feuille, qui peut à son tour avoir un "then"
|
||||
# (borné par _MAX_THEN_CHAIN) — demande explicite : "pouvoir modifier un
|
||||
@@ -67,15 +109,25 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
#
|
||||
# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant,
|
||||
# demande explicite.)
|
||||
ACTION_TYPES = ["dialogue", "variable", "interagir", "condition"]
|
||||
LEAF_ACTION_TYPES = ("dialogue", "variable")
|
||||
TRIGGER_TYPES = ["collision"]
|
||||
ACTION_TYPES = [
|
||||
"dialogue", "variable", "surbrillance", "visibilite", "son", "video", "indication", "attendre",
|
||||
"interagir", "condition",
|
||||
]
|
||||
TRIGGER_TYPES = ["collision", "clic", "survol"]
|
||||
CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS}
|
||||
# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une
|
||||
# structure sans fin construite par un JSON posté à la main.
|
||||
_MAX_ACTION_DEPTH = 2
|
||||
# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH.
|
||||
_MAX_THEN_CHAIN = 4
|
||||
# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH
|
||||
# (jamais illimité, mais large : voir "+ Ajouter une action" posé après
|
||||
# CHAQUE bloc, demande explicite "n'importe quel niveau du schéma"). Bug
|
||||
# corrigé : à 4, une chaîne suffisait à peine à enchaîner attendre ->
|
||||
# dialogue -> surbrillance -> attendre — le 5e maillon ajouté par
|
||||
# l'utilisateur (ex. une seconde surbrillance pour désactiver) était
|
||||
# TRONQUÉ EN SILENCE par la resanitisation qui suit chaque écriture
|
||||
# (set_then_action_in_rules), donnant l'impression que l'ajout n'avait
|
||||
# aucun effet alors qu'il avait bien été inséré en mémoire.
|
||||
_MAX_THEN_CHAIN = 30
|
||||
|
||||
|
||||
_MAX_ACTION_ID_LENGTH = 40
|
||||
@@ -129,20 +181,112 @@ def _sanitize_variable_action(raw_action):
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_object_id(raw_action):
|
||||
object_id = raw_action.get("object_id")
|
||||
try:
|
||||
return int(object_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _with_optional_id(action, raw_action):
|
||||
"""Ajoute le champ `id` (optionnel, généré côté client) commun à
|
||||
toutes les feuilles autres que "dialogue" — sert à adresser CETTE
|
||||
feuille précise pour y attacher un "then" plus tard (voir
|
||||
set_then_action_in_rules), même rôle que `variable.id`."""
|
||||
action_id = raw_action.get("id")
|
||||
if isinstance(action_id, str) and action_id.strip():
|
||||
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_surbrillance_action(raw_action):
|
||||
object_id = _sanitize_object_id(raw_action)
|
||||
valeur = raw_action.get("valeur")
|
||||
if object_id is None or valeur not in SURBRILLANCE_LABELS:
|
||||
return None
|
||||
return _with_optional_id({"type": "surbrillance", "object_id": object_id, "valeur": valeur}, raw_action)
|
||||
|
||||
|
||||
def _sanitize_visibilite_action(raw_action):
|
||||
object_id = _sanitize_object_id(raw_action)
|
||||
valeur = raw_action.get("valeur")
|
||||
if object_id is None or valeur not in ELEMENT_VISIBILITY_LABELS:
|
||||
return None
|
||||
return _with_optional_id({"type": "visibilite", "object_id": object_id, "valeur": valeur}, raw_action)
|
||||
|
||||
|
||||
def _sanitize_son_action(raw_action):
|
||||
asset_url = raw_action.get("asset_url")
|
||||
if not isinstance(asset_url, str) or not asset_url.strip():
|
||||
return None
|
||||
return _with_optional_id({"type": "son", "asset_url": asset_url.strip()}, raw_action)
|
||||
|
||||
|
||||
def _sanitize_video_action(raw_action):
|
||||
asset_url = raw_action.get("asset_url")
|
||||
mode = raw_action.get("mode")
|
||||
if not isinstance(asset_url, str) or not asset_url.strip() or mode not in VIDEO_MODE_LABELS:
|
||||
return None
|
||||
return _with_optional_id({"type": "video", "asset_url": asset_url.strip(), "mode": mode}, raw_action)
|
||||
|
||||
|
||||
def _sanitize_indication_action(raw_action):
|
||||
object_id = _sanitize_object_id(raw_action)
|
||||
texte = raw_action.get("texte")
|
||||
if object_id is None or not isinstance(texte, str) or not texte.strip():
|
||||
return None
|
||||
action = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
|
||||
duree_ms = raw_action.get("duree_ms")
|
||||
if isinstance(duree_ms, (int, float)) and not isinstance(duree_ms, bool) and duree_ms > 0:
|
||||
action["duree_ms"] = int(duree_ms)
|
||||
return _with_optional_id(action, raw_action)
|
||||
|
||||
|
||||
def _sanitize_attendre_action(raw_action):
|
||||
""""Attendre N secondes" avant de continuer la chaîne — MÊME
|
||||
vocabulaire que l'action "attendre" du Flow (voir static/js/play/
|
||||
actions.js, `data_value` en SECONDES, pas en ms) : suspend le "then"
|
||||
plutôt que de l'exécuter immédiatement (comme "video"), utile par ex.
|
||||
pour une narration à l'affichage de l'écran qui doit laisser un temps
|
||||
de lecture avant d'enchaîner."""
|
||||
raw_value = raw_action.get("data_value")
|
||||
try:
|
||||
seconds = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if seconds < 0:
|
||||
return None
|
||||
return _with_optional_id({"type": "attendre", "data_value": str(seconds)}, raw_action)
|
||||
|
||||
|
||||
# Une seule table de vérité pour les feuilles : ajouter un type ici suffit
|
||||
# (voir LEAF_ACTION_TYPES dérivé de ses clés) — évite le if/elif qui aurait
|
||||
# dû être répété à chaque nouvel ajout.
|
||||
_LEAF_ACTION_SANITIZERS = {
|
||||
"dialogue": _sanitize_dialogue_action,
|
||||
"variable": _sanitize_variable_action,
|
||||
"surbrillance": _sanitize_surbrillance_action,
|
||||
"visibilite": _sanitize_visibilite_action,
|
||||
"son": _sanitize_son_action,
|
||||
"video": _sanitize_video_action,
|
||||
"indication": _sanitize_indication_action,
|
||||
"attendre": _sanitize_attendre_action,
|
||||
}
|
||||
LEAF_ACTION_TYPES = tuple(_LEAF_ACTION_SANITIZERS)
|
||||
|
||||
|
||||
def _sanitize_leaf_action(raw_action, depth=1):
|
||||
"""Sanitize une FEUILLE ("dialogue" ou "variable") et son éventuelle
|
||||
"""Sanitize une FEUILLE (voir LEAF_ACTION_TYPES) et son éventuelle
|
||||
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
|
||||
feuille est attendue : action de premier niveau, sous "interagir", ou
|
||||
branche Vrai/Faux de "condition"."""
|
||||
if not isinstance(raw_action, dict):
|
||||
return None
|
||||
action_type = raw_action.get("type")
|
||||
if action_type == "dialogue":
|
||||
action = _sanitize_dialogue_action(raw_action)
|
||||
elif action_type == "variable":
|
||||
action = _sanitize_variable_action(raw_action)
|
||||
else:
|
||||
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type"))
|
||||
if not sanitizer:
|
||||
return None
|
||||
action = sanitizer(raw_action)
|
||||
if not action:
|
||||
return None
|
||||
if depth < _MAX_THEN_CHAIN:
|
||||
@@ -193,11 +337,17 @@ def _sanitize_action(raw_action, depth=1):
|
||||
}
|
||||
|
||||
|
||||
def sanitize_collision_rules(raw_rules):
|
||||
def sanitize_collision_rules(raw_rules, allowed_triggers=None):
|
||||
"""Valide/nettoie une liste de règles postée par le client (voir
|
||||
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
|
||||
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
|
||||
la sauvegarde entière."""
|
||||
la sauvegarde entière. `allowed_triggers` (par défaut TRIGGER_TYPES) :
|
||||
réutilisé tel quel par screens/rendering/screen_triggers.py pour les
|
||||
déclencheurs D'ÉCRAN (sans objet), qui partagent la même logique de
|
||||
sanitisation d'action mais un vocabulaire de déclencheur différent
|
||||
(seulement "affichage")."""
|
||||
if allowed_triggers is None:
|
||||
allowed_triggers = TRIGGER_TYPES
|
||||
if not isinstance(raw_rules, list):
|
||||
return []
|
||||
cleaned = []
|
||||
@@ -205,7 +355,7 @@ def sanitize_collision_rules(raw_rules):
|
||||
if not isinstance(raw_rule, dict):
|
||||
continue
|
||||
trigger = raw_rule.get("trigger")
|
||||
if trigger not in TRIGGER_TYPES:
|
||||
if trigger not in allowed_triggers:
|
||||
continue
|
||||
action = _sanitize_action(raw_rule.get("action"))
|
||||
if not action:
|
||||
@@ -224,7 +374,7 @@ def _apply_dialogue_lines(action, dialogue_id, lines):
|
||||
action["dialogue"] = {"id": dialogue_id, "lines": lines}
|
||||
return True
|
||||
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
|
||||
if action_type == "variable":
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
|
||||
if action_type == "interagir":
|
||||
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
|
||||
@@ -255,7 +405,7 @@ def _leaf_id(action):
|
||||
if action.get("type") == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
return dialogue.get("id") if isinstance(dialogue, dict) else None
|
||||
if action.get("type") == "variable":
|
||||
if action.get("type") in LEAF_ACTION_TYPES:
|
||||
return action.get("id")
|
||||
return None
|
||||
|
||||
@@ -266,8 +416,15 @@ def _apply_then_action(action, leaf_id, then_action):
|
||||
action_type = action.get("type")
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
if _leaf_id(action) == leaf_id:
|
||||
if "then" in action:
|
||||
return False # jamais écraser un chaînage déjà posé
|
||||
# INSÈRE `then_action` juste après cette feuille — l'éventuelle
|
||||
# suite déjà présente passe DERRIÈRE elle plutôt que d'être
|
||||
# écrasée (demande explicite : "pouvoir ajouter n'importe
|
||||
# quelle action à n'importe quel niveau du schéma", pas
|
||||
# seulement en bout de chaîne — comportement précédent, qui
|
||||
# refusait si "then" existait déjà).
|
||||
existing_then = action.get("then")
|
||||
if existing_then:
|
||||
then_action["then"] = existing_then
|
||||
action["then"] = then_action
|
||||
return True
|
||||
return _apply_then_action(action.get("then"), leaf_id, then_action)
|
||||
@@ -282,15 +439,16 @@ def _apply_then_action(action, leaf_id, then_action):
|
||||
|
||||
|
||||
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
|
||||
"""Attache EN PLACE une NOUVELLE action `then` sur la feuille
|
||||
("dialogue" ou "variable") identifiée par `leaf_id` (dialogue.id pour
|
||||
une feuille dialogue, id pour une feuille variable) — n'importe où
|
||||
dans l'arbre d'action d'une des règles. Utilisé par routes/triggers/
|
||||
pour "+ Ajouter une action" en bout de chaîne (voir static/js/triggers/
|
||||
trigger-editor.js). Retourne False sans rien modifier si `leaf_id`
|
||||
est introuvable, OU si la feuille trouvée a DÉJÀ un "then" (on
|
||||
n'écrase jamais un chaînage existant par cette route — seulement
|
||||
ajouter à la fin réelle de la chaîne)."""
|
||||
"""INSÈRE EN PLACE une NOUVELLE action juste après la feuille
|
||||
identifiée par `leaf_id` (dialogue.id pour une feuille dialogue, id
|
||||
pour toute autre feuille) — n'importe où dans l'arbre d'action d'une
|
||||
des règles. Utilisé par "+ Ajouter une action", posé après CHAQUE
|
||||
bloc de la chaîne (voir static/js/triggers/trigger-editor.js) : si la
|
||||
feuille visée a déjà une suite ("then"), celle-ci passe DERRIÈRE la
|
||||
nouvelle action plutôt que d'être remplacée — permet d'insérer une
|
||||
action à N'IMPORTE QUEL niveau, pas seulement en bout de chaîne
|
||||
réelle. Retourne False sans rien modifier si `leaf_id` est
|
||||
introuvable."""
|
||||
then_action = _sanitize_leaf_action(raw_then_action)
|
||||
if not then_action:
|
||||
return False
|
||||
@@ -300,6 +458,211 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
|
||||
return False
|
||||
|
||||
|
||||
# ---------- Suppression/réordonnancement d'un maillon précis (demande
|
||||
# explicite : "que chaque bloc étiquette + action soit déplaçable ou
|
||||
# supprimable par l'utilisateur ET par l'IA") ----------
|
||||
# Les deux opérations ci-dessous reconstruisent l'arbre par le HAUT
|
||||
# (renvoient un NOUVEL arbre plutôt que de muter les dicts en place, voir
|
||||
# _remove_leaf_id/_move_in_action_tree) — plus sûr qu'un patch en place
|
||||
# pour un maillon qui peut être la TÊTE d'une règle (auquel cas la
|
||||
# RÈGLE elle-même doit être remplacée ou retirée, pas juste un champ
|
||||
# interne).
|
||||
|
||||
def _remove_leaf_id(action, leaf_id):
|
||||
"""Retire la feuille `leaf_id` de l'arbre `action` — renvoie
|
||||
(nouvel_arbre_ou_None, trouvé). `nouvel_arbre` vaut None si `action`
|
||||
lui-même était la cible et n'avait rien pour le remplacer (ni "then",
|
||||
ni sous-action restante pour "interagir")."""
|
||||
if not isinstance(action, dict):
|
||||
return action, False
|
||||
action_type = action.get("type")
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
if _leaf_id(action) == leaf_id:
|
||||
return action.get("then"), True
|
||||
new_then, removed = _remove_leaf_id(action.get("then"), leaf_id)
|
||||
if not removed:
|
||||
return action, False
|
||||
new_action = dict(action)
|
||||
if new_then:
|
||||
new_action["then"] = new_then
|
||||
else:
|
||||
new_action.pop("then", None)
|
||||
return new_action, True
|
||||
if action_type == "interagir":
|
||||
new_sub, removed = _remove_leaf_id(action.get("sub_action"), leaf_id)
|
||||
if not removed:
|
||||
return action, False
|
||||
if new_sub is None:
|
||||
# Plus de sous-action : "interagir" perd son sens (une touche
|
||||
# qui ne déclenche plus rien), retiré à son tour.
|
||||
return None, True
|
||||
new_action = dict(action)
|
||||
new_action["sub_action"] = new_sub
|
||||
return new_action, True
|
||||
if action_type == "condition":
|
||||
new_action = dict(action)
|
||||
changed = False
|
||||
for branch_key in ("si_vrai", "si_faux"):
|
||||
new_branch, removed = _remove_leaf_id(action.get(branch_key), leaf_id)
|
||||
if removed:
|
||||
new_action[branch_key] = new_branch
|
||||
changed = True
|
||||
return (new_action, True) if changed else (action, False)
|
||||
return action, False
|
||||
|
||||
|
||||
def remove_action_from_rules(rules, leaf_id):
|
||||
"""Retire EN PLACE l'action `leaf_id` n'importe où dans l'arbre d'une
|
||||
des règles (feuille directe d'une règle, maillon d'une chaîne "then",
|
||||
sous-action d'un "interagir", ou dans une branche de "condition"). Si
|
||||
l'action retirée était la TÊTE d'une règle : la règle est remplacée
|
||||
par sa suite ("then") si elle en avait une, sinon la RÈGLE ENTIÈRE
|
||||
disparaît (plus rien à garder). Renvoie True si quelque chose a été
|
||||
retiré."""
|
||||
for i, rule in enumerate(rules):
|
||||
new_action, removed = _remove_leaf_id(rule.get("action"), leaf_id)
|
||||
if not removed:
|
||||
continue
|
||||
if new_action is None:
|
||||
del rules[i]
|
||||
else:
|
||||
rules[i] = {"trigger": rule["trigger"], "action": new_action}
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _chain_as_list(head_action):
|
||||
"""Aplatit une chaîne de FEUILLES pures (voir LEAF_ACTION_TYPES) en
|
||||
liste — le réordonnancement ne s'applique qu'à l'intérieur d'une
|
||||
chaîne "then" (jamais entre deux branches différentes d'une
|
||||
condition, ni entre une "interagir" et ce qui l'entoure)."""
|
||||
items = []
|
||||
node = head_action
|
||||
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
|
||||
items.append(node)
|
||||
node = node.get("then")
|
||||
return items
|
||||
|
||||
|
||||
def _relink_chain(items):
|
||||
items = [dict(n) for n in items]
|
||||
for i in range(len(items) - 1):
|
||||
items[i]["then"] = items[i + 1]
|
||||
if items:
|
||||
items[-1].pop("then", None)
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def _move_in_action_tree(action, leaf_id, direction):
|
||||
"""Cherche, dans `action`, LA chaîne "then" qui contient `leaf_id`
|
||||
(au premier niveau, sous une "interagir", ou dans une branche de
|
||||
"condition"), et échange `leaf_id` avec son voisin immédiat
|
||||
("up"/"down") DANS CETTE CHAÎNE. Renvoie le nouvel arbre, ou None si
|
||||
`leaf_id` n'a été trouvé nulle part dans `action` (jamais rien à
|
||||
changer dans ce cas — distinct d'un déplacement sans effet parce que
|
||||
déjà en bout de chaîne, qui renvoie quand même un arbre, identique à
|
||||
l'original)."""
|
||||
if not isinstance(action, dict):
|
||||
return None
|
||||
action_type = action.get("type")
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
chain = _chain_as_list(action)
|
||||
ids = [_leaf_id(n) for n in chain]
|
||||
if leaf_id not in ids:
|
||||
return None
|
||||
idx = ids.index(leaf_id)
|
||||
swap_idx = idx - 1 if direction == "up" else idx + 1
|
||||
if 0 <= swap_idx < len(chain):
|
||||
chain[idx], chain[swap_idx] = chain[swap_idx], chain[idx]
|
||||
return _relink_chain(chain)
|
||||
if action_type == "interagir":
|
||||
new_sub = _move_in_action_tree(action.get("sub_action"), leaf_id, direction)
|
||||
if new_sub is None:
|
||||
return None
|
||||
new_action = dict(action)
|
||||
new_action["sub_action"] = new_sub
|
||||
return new_action
|
||||
if action_type == "condition":
|
||||
for branch_key in ("si_vrai", "si_faux"):
|
||||
new_branch = _move_in_action_tree(action.get(branch_key), leaf_id, direction)
|
||||
if new_branch is not None:
|
||||
new_action = dict(action)
|
||||
new_action[branch_key] = new_branch
|
||||
return new_action
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def move_action_in_rules(rules, leaf_id, direction):
|
||||
"""Déplace EN PLACE la feuille `leaf_id` d'un cran vers le haut
|
||||
("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then" (jamais
|
||||
au-delà de sa branche de condition/sous-action interagir — voir
|
||||
_move_in_action_tree). `direction` autre que "up"/"down" : aucun
|
||||
effet. Renvoie True si `leaf_id` a été trouvé (même si déjà en bout
|
||||
de chaîne et donc sans déplacement réel — la règle est alors
|
||||
réécrite à l'identique)."""
|
||||
if direction not in ("up", "down"):
|
||||
return False
|
||||
for i, rule in enumerate(rules):
|
||||
new_action = _move_in_action_tree(rule.get("action"), leaf_id, direction)
|
||||
if new_action is not None:
|
||||
rules[i] = {"trigger": rule["trigger"], "action": new_action}
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _generate_leaf_id():
|
||||
return "d_" + uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def _backfill_leaf_ids(action):
|
||||
"""Assure EN PLACE qu'un id STABLE existe sur toute feuille non-
|
||||
"dialogue" (qui a déjà le sien via dialogue.id) qui n'en avait pas —
|
||||
ex. une action "variable" posée AVANT que ce champ ne devienne
|
||||
systématique, ou par l'IA (le champ reste optionnel dans son schéma,
|
||||
voir ai/tools.py). Sans id, une feuille reste à jamais impossible à
|
||||
adresser (chaînage/ajout/retrait/déplacement ciblé, voir
|
||||
"+ Ajouter une action" posé après CHAQUE bloc). Renvoie True si
|
||||
quelque chose a été ajouté — l'appelant doit alors PERSISTER `action`
|
||||
(voir backfill_missing_leaf_ids), sans quoi l'id resterait instable
|
||||
d'une lecture à l'autre (régénéré à chaque appel)."""
|
||||
if not isinstance(action, dict):
|
||||
return False
|
||||
changed = False
|
||||
action_type = action.get("type")
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
if action_type != "dialogue" and not action.get("id"):
|
||||
action["id"] = _generate_leaf_id()
|
||||
changed = True
|
||||
if _backfill_leaf_ids(action.get("then")):
|
||||
changed = True
|
||||
elif action_type == "interagir":
|
||||
if _backfill_leaf_ids(action.get("sub_action")):
|
||||
changed = True
|
||||
elif action_type == "condition":
|
||||
if _backfill_leaf_ids(action.get("si_vrai")):
|
||||
changed = True
|
||||
if _backfill_leaf_ids(action.get("si_faux")):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def backfill_missing_leaf_ids(rules):
|
||||
"""Applique _backfill_leaf_ids à CHAQUE règle de `rules` (mutées EN
|
||||
PLACE) — renvoie True si au moins une règle a été modifiée, auquel
|
||||
cas l'appelant DOIT persister `rules` (voir routes/triggers/
|
||||
triggers.py::triggers_get, routes/screens/screen_triggers.py::
|
||||
screen_triggers_get, screens/rendering/trigger_graph.py::
|
||||
list_all_triggers — appelé à la LECTURE, pour que le premier
|
||||
affichage d'une règle ancienne rende ses feuilles adressables une
|
||||
bonne fois pour toutes)."""
|
||||
changed = False
|
||||
for rule in rules:
|
||||
if _backfill_leaf_ids(rule.get("action")):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def resolve_collision_rules(obj):
|
||||
"""Décode attributes._collision_rules en liste Python, jamais None —
|
||||
un attribut absent/corrompu retombe sur aucune règle (comportement
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
|
||||
from .collision_rules import sanitize_collision_rules
|
||||
|
||||
# Déclencheurs D'ÉCRAN (voir _screens._screen_triggers, screens_repo/
|
||||
# ensure_schema.py) — contrairement à _collision_rules (porté par un
|
||||
# OBJET de scène), ceux-ci se déclenchent sans avoir besoin d'un objet :
|
||||
# demande explicite pour de la narration/cinématique dès l'affichage
|
||||
# d'un écran. Seul déclencheur pour l'instant : "affichage" (à
|
||||
# l'affichage de l'écran) — même forme d'action que collision_rules.py
|
||||
# (dialogue/variable/surbrillance/visibilite/son/video/indication/
|
||||
# interagir/condition), réutilisée telle quelle plutôt que dupliquée.
|
||||
TRIGGER_TYPES_SCREEN = ["affichage"]
|
||||
|
||||
|
||||
def sanitize_screen_triggers(raw_rules):
|
||||
"""Valide/nettoie une liste de règles "déclencheur d'écran -> action"
|
||||
postée par le client (voir routes/screens/screen_triggers.py) — même
|
||||
garanties que sanitize_collision_rules, juste un vocabulaire de
|
||||
déclencheur restreint à TRIGGER_TYPES_SCREEN."""
|
||||
return sanitize_collision_rules(raw_rules, allowed_triggers=TRIGGER_TYPES_SCREEN)
|
||||
|
||||
|
||||
def resolve_screen_triggers(screen):
|
||||
"""Décode _screens._screen_triggers en liste Python, jamais None — un
|
||||
écran créé avant l'ajout de cette fonctionnalité (colonne NULL)
|
||||
retombe sur aucune règle, même principe que resolve_collision_rules
|
||||
pour un objet."""
|
||||
raw = (screen or {}).get("_screen_triggers")
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return sanitize_screen_triggers(parsed)
|
||||
@@ -17,36 +17,49 @@ def _game_objects_with_thumbnails(slug, screens_mod):
|
||||
return result
|
||||
|
||||
|
||||
from .collision_rules import LEAF_ACTION_TYPES
|
||||
|
||||
# Champs affichés pour chaque type de feuille (voir screens/rendering/
|
||||
# collision_rules.py pour la forme exacte de chacune) — un simple filtre
|
||||
# sur l'action déjà sanitizée, jamais une recopie de valeur : ajouter un
|
||||
# type de feuille suffit à l'ajouter ici, sans dupliquer sa logique de
|
||||
# sanitisation.
|
||||
_LEAF_DISPLAY_FIELDS = {
|
||||
"dialogue": ("dialogue",),
|
||||
"variable": ("id", "target_variable", "data_operation", "data_value"),
|
||||
"surbrillance": ("id", "object_id", "valeur"),
|
||||
"visibilite": ("id", "object_id", "valeur"),
|
||||
"son": ("id", "asset_url"),
|
||||
"video": ("id", "asset_url", "mode"),
|
||||
"indication": ("id", "object_id", "texte", "duree_ms"),
|
||||
"attendre": ("id", "data_value"),
|
||||
}
|
||||
|
||||
|
||||
def _describe_leaf(action):
|
||||
"""Une seule feuille ("dialogue" ou "variable", voir
|
||||
screens/rendering/collision_rules.py) décrite pour l'affichage,
|
||||
SANS son éventuel "then" (voir _describe_chain, qui aplatit la
|
||||
chaîne complète)."""
|
||||
if action.get("type") == "dialogue":
|
||||
return {"type": "dialogue", "dialogue": action.get("dialogue")}
|
||||
return {
|
||||
"type": "variable",
|
||||
"id": action.get("id"),
|
||||
"target_variable": action.get("target_variable"),
|
||||
"data_operation": action.get("data_operation"),
|
||||
"data_value": action.get("data_value"),
|
||||
}
|
||||
"""Une seule feuille (voir LEAF_ACTION_TYPES, screens/rendering/
|
||||
collision_rules.py) décrite pour l'affichage, SANS son éventuel
|
||||
"then" (voir _describe_chain, qui aplatit la chaîne complète)."""
|
||||
action_type = action.get("type")
|
||||
fields = _LEAF_DISPLAY_FIELDS.get(action_type, ())
|
||||
return {"type": action_type, **{f: action[f] for f in fields if f in action}}
|
||||
|
||||
|
||||
def _describe_chain(action):
|
||||
"""Aplatit une feuille + ses "then" successifs en une LISTE, dans
|
||||
l'ordre d'exécution — voir maquette validée ("plusieurs actions
|
||||
chaînées"). Liste vide si `action` n'est ni "dialogue" ni "variable"."""
|
||||
chaînées"). Liste vide si `action` n'est pas une feuille reconnue
|
||||
(voir LEAF_ACTION_TYPES)."""
|
||||
chain = []
|
||||
node = action
|
||||
while isinstance(node, dict) and node.get("type") in ("dialogue", "variable"):
|
||||
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
|
||||
chain.append(_describe_leaf(node))
|
||||
node = node.get("then")
|
||||
return chain
|
||||
|
||||
|
||||
def _branch_info(branch):
|
||||
if not isinstance(branch, dict) or branch.get("type") not in ("dialogue", "variable"):
|
||||
if not isinstance(branch, dict) or branch.get("type") not in LEAF_ACTION_TYPES:
|
||||
return None
|
||||
return {"chain": _describe_chain(branch)}
|
||||
|
||||
@@ -54,19 +67,19 @@ def _branch_info(branch):
|
||||
def _resolve_action_trigger(action, has_interagir=False):
|
||||
"""Décrit ENTIÈREMENT un arbre d'action (voir screens/rendering/
|
||||
collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" —
|
||||
chaque feuille ("dialogue"/"variable") affiche DIRECTEMENT son propre
|
||||
contenu, où qu'elle soit dans l'arbre (y compris CHAQUE branche d'une
|
||||
"condition", indépendamment l'une de l'autre), et sa chaîne "then"
|
||||
complète (voir _describe_chain). Retourne {has_interagir, chain,
|
||||
condition} — `chain` est une LISTE (jamais None, vide si l'action ne
|
||||
se termine par aucune feuille reconnue) ; `condition` est None sauf
|
||||
pour une action "condition", auquel cas il porte {variable, operateur,
|
||||
valeur, si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne
|
||||
rien faire") ou {chain: [...]}."""
|
||||
chaque feuille affiche DIRECTEMENT son propre contenu, où qu'elle soit
|
||||
dans l'arbre (y compris CHAQUE branche d'une "condition",
|
||||
indépendamment l'une de l'autre), et sa chaîne "then" complète (voir
|
||||
_describe_chain). Retourne {has_interagir, chain, condition} —
|
||||
`chain` est une LISTE (jamais None, vide si l'action ne se termine
|
||||
par aucune feuille reconnue) ; `condition` est None sauf pour une
|
||||
action "condition", auquel cas il porte {variable, operateur, valeur,
|
||||
si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne rien
|
||||
faire") ou {chain: [...]}."""
|
||||
if not isinstance(action, dict):
|
||||
return {"has_interagir": has_interagir, "chain": [], "condition": None}
|
||||
action_type = action.get("type")
|
||||
if action_type in ("dialogue", "variable"):
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
return {"has_interagir": has_interagir, "chain": _describe_chain(action), "condition": None}
|
||||
if action_type == "interagir":
|
||||
return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True)
|
||||
@@ -96,6 +109,15 @@ def list_all_triggers(slug):
|
||||
|
||||
result = []
|
||||
for obj in rule_objects:
|
||||
# Rétro-remplissage des feuilles posées avant que "id" ne devienne
|
||||
# systématique (ex. par l'IA, ce champ restant optionnel dans son
|
||||
# schéma) — sans ça, "+ Ajouter une action"/le retrait/le
|
||||
# déplacement restent silencieusement indisponibles sur ce bloc
|
||||
# précis (voir screens/rendering/collision_rules.py::
|
||||
# backfill_missing_leaf_ids). Persisté dès ce premier affichage,
|
||||
# jamais régénéré ensuite.
|
||||
if screens_mod.backfill_missing_leaf_ids(obj["rules"]):
|
||||
screens_mod.set_scene_object_collision_rules(slug, obj["id"], obj["rules"])
|
||||
for rule in obj["rules"]:
|
||||
info = _resolve_action_trigger(rule["action"])
|
||||
result.append({
|
||||
@@ -119,7 +141,7 @@ def _collect_dialogue_lines_from_action(action, acc):
|
||||
if isinstance(dialogue, dict):
|
||||
acc.append(dialogue.get("lines") or [])
|
||||
_collect_dialogue_lines_from_action(action.get("then"), acc)
|
||||
elif action_type == "variable":
|
||||
elif action_type in LEAF_ACTION_TYPES:
|
||||
_collect_dialogue_lines_from_action(action.get("then"), acc)
|
||||
elif action_type == "interagir":
|
||||
_collect_dialogue_lines_from_action(action.get("sub_action"), acc)
|
||||
@@ -145,6 +167,8 @@ def collect_all_dialogue_lines(slug):
|
||||
for obj in screens_mod.list_scene_objects(slug, s["id"]):
|
||||
for rule in screens_mod.resolve_collision_rules(obj):
|
||||
_collect_dialogue_lines_from_action(rule.get("action"), acc)
|
||||
for rule in screens_mod.resolve_screen_triggers(s):
|
||||
_collect_dialogue_lines_from_action(rule.get("action"), acc)
|
||||
return acc
|
||||
|
||||
|
||||
@@ -157,7 +181,7 @@ def _collect_completable_ids_from_action(action, acc):
|
||||
if action.get("mark_completed") and isinstance(dialogue, dict) and dialogue.get("id"):
|
||||
acc.append(dialogue["id"])
|
||||
_collect_completable_ids_from_action(action.get("then"), acc)
|
||||
elif action_type == "variable":
|
||||
elif action_type in LEAF_ACTION_TYPES:
|
||||
_collect_completable_ids_from_action(action.get("then"), acc)
|
||||
elif action_type == "interagir":
|
||||
_collect_completable_ids_from_action(action.get("sub_action"), acc)
|
||||
@@ -182,6 +206,8 @@ def list_completable_dialogue_ids(slug):
|
||||
for obj in screens_mod.list_scene_objects(slug, s["id"]):
|
||||
for rule in screens_mod.resolve_collision_rules(obj):
|
||||
_collect_completable_ids_from_action(rule.get("action"), acc)
|
||||
for rule in screens_mod.resolve_screen_triggers(s):
|
||||
_collect_completable_ids_from_action(rule.get("action"), acc)
|
||||
return acc
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,18 @@ _KINDS = {"personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widg
|
||||
_DIALOGUE_BOX_DEFAULT_SIZE = (320, 150)
|
||||
_QUIZ_BOX_DEFAULT_SIZE = (340, 240)
|
||||
_SCORE_WIDGET_DEFAULT_SIZE = (140, 50)
|
||||
# "decor" (demande explicite) : taille fixe à la pose, JAMAIS la taille
|
||||
# réelle de l'image importée (contrairement à "fond", voir plus bas) —
|
||||
# une photo/illustration "Mes assets" est souvent bien plus grande qu'un
|
||||
# petit objet de décor, 200×200 reste un point de départ raisonnable à
|
||||
# ajuster ensuite à la poignée. La boîte de collision, elle, est posée
|
||||
# plus PETITE que l'objet (150×150, centrée — voir collision_settings.py)
|
||||
# pour rester proche de la silhouette réelle plutôt que du cadre entier.
|
||||
_DECOR_DEFAULT_SIZE = (200, 200)
|
||||
_DECOR_COLLISION_SIZE = (150, 150)
|
||||
|
||||
|
||||
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None, image_url=None):
|
||||
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None, image_url=None, image_width=None, image_height=None, x=None, y=None):
|
||||
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
|
||||
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
|
||||
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
|
||||
@@ -33,9 +42,20 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
image_url (kind "fond"/"decor" uniquement) : une image ARBITRAIRE
|
||||
(voir "Mes assets", auth/user_assets/) plutôt qu'une clé de
|
||||
BACKGROUND_LIBRARY — prioritaire sur background_slug si les deux sont
|
||||
fournis. Pas de largeur/hauteur connues d'avance ici (contrairement à
|
||||
BACKGROUND_LIBRARY qui les documente) : retombe sur les colonnes par
|
||||
défaut de _scene_objects, exactement comme un "decor" ordinaire."""
|
||||
fournis. image_width/image_height (kind "fond" uniquement, voir
|
||||
routes/scenes/scene_object_add.py::auth.image_dimensions) : taille
|
||||
RÉELLE de l'image quand connue, pour que le "monde" puisse s'agrandir
|
||||
à sa vraie taille. Un "decor" les ignore volontairement (demande
|
||||
explicite) : toujours posé à _DECOR_DEFAULT_SIZE (200×200), quelle que
|
||||
soit la taille réelle de l'image importée.
|
||||
|
||||
x/y (optionnels) : position EXACTE demandée par le créateur (voir
|
||||
"vrai glisser-déposer" depuis la galerie, static/js/scenes/
|
||||
scene-editor.js::onGalleryDragStart/onCanvasDrop) — remplace le
|
||||
défaut habituel (100,100 posé par le schéma, voir
|
||||
ensure_scene_schema.py) pour TOUT type d'objet, y compris "fond"
|
||||
(qui, SANS position explicite, continue de démarrer au coin
|
||||
haut-gauche du monde comme avant)."""
|
||||
ensure_scene_schema(slug)
|
||||
kind = kind if kind in _KINDS else "personnage"
|
||||
attributes = {}
|
||||
@@ -59,8 +79,16 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
# et disparaîtrait à tort de "🧩 Collision" (voir scene_edit_view.py,
|
||||
# qui exclut toujours le joueur).
|
||||
attributes["_personnage_role"] = "pnj"
|
||||
elif kind in ("fond", "decor") and image_url:
|
||||
elif kind == "fond" and image_url:
|
||||
attributes["src"] = image_url
|
||||
if image_width is not None and image_height is not None:
|
||||
width, height = image_width, image_height
|
||||
elif kind == "decor" and image_url:
|
||||
attributes["src"] = image_url
|
||||
width, height = _DECOR_DEFAULT_SIZE
|
||||
attributes["_collision"] = json.dumps({
|
||||
"width": _DECOR_COLLISION_SIZE[0], "height": _DECOR_COLLISION_SIZE[1],
|
||||
})
|
||||
elif kind == "fond" and background_slug:
|
||||
from ..labels.background_library import BACKGROUND_LIBRARY
|
||||
background = BACKGROUND_LIBRARY.get(background_slug)
|
||||
@@ -83,11 +111,17 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
||||
cols = ["scene_id", "kind", "attributes", "z_index"]
|
||||
values = [scene_id, kind, json.dumps(attributes), z_index]
|
||||
if width is not None:
|
||||
# x=0/y=0 (jamais le défaut 100/100, voir ensure_scene_schema.py) :
|
||||
# un fond commence au coin haut-gauche de la scène/du monde, pas
|
||||
# décalé arbitrairement comme un objet ordinaire.
|
||||
cols += ["width", "height", "x", "y"]
|
||||
values += [width, height, 0, 0]
|
||||
cols += ["width", "height"]
|
||||
values += [width, height]
|
||||
if x is None and y is None and kind == "fond":
|
||||
# Comportement inchangé pour un fond posé SANS position explicite
|
||||
# (galerie Forge/Mes assets par simple clic) : jamais le défaut
|
||||
# 100/100 (voir ensure_scene_schema.py), toujours le coin
|
||||
# haut-gauche de la scène/du monde.
|
||||
x, y = 0, 0
|
||||
if x is not None and y is not None:
|
||||
cols += ["x", "y"]
|
||||
values += [x, y]
|
||||
placeholders = ", ".join(["?"] * len(values))
|
||||
conn.execute(f"INSERT INTO _scene_objects ({', '.join(cols)}) VALUES ({placeholders})", values)
|
||||
object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
|
||||
@@ -4,16 +4,18 @@ from ..rendering.collision_rules import resolve_collision_rules, sanitize_collis
|
||||
|
||||
|
||||
def append_action_to_trigger(slug, object_id, after_id, raw_action):
|
||||
""""+ Ajouter une action" en bout de chaîne — extrait de
|
||||
routes/triggers/triggers.py::trigger_append_action pour être
|
||||
l'UNIQUE chemin d'écriture, partagé par la route HTTP ET par l'agent
|
||||
IA (voir ai/tools.py) : ni l'un ni l'autre n'écrit `_collision_rules`
|
||||
directement, tous deux passent par ici. `after_id` : dialogue.id
|
||||
(feuille "dialogue") ou id (feuille "variable") de la DERNIÈRE
|
||||
feuille d'une chaîne. Renvoie False si l'objet, `after_id`, ou
|
||||
`raw_action` sont invalides, ou si la feuille visée a déjà un "then"
|
||||
(jamais écrasé par cette fonction — set_then_action_in_rules ne fait
|
||||
qu'ajouter en bout de chaîne réelle)."""
|
||||
""""+ Ajouter une action" — extrait de routes/triggers/triggers.py::
|
||||
trigger_append_action pour être l'UNIQUE chemin d'écriture, partagé
|
||||
par la route HTTP ET par l'agent IA (voir ai/tools.py) : ni l'un ni
|
||||
l'autre n'écrit `_collision_rules` directement, tous deux passent par
|
||||
ici. `after_id` : dialogue.id (feuille "dialogue") ou id (toute autre
|
||||
feuille) — INSÈRE `raw_action` juste après cette feuille, où qu'elle
|
||||
soit dans la chaîne (pas seulement en bout réel — voir
|
||||
set_then_action_in_rules, demande explicite : "pouvoir ajouter
|
||||
n'importe quelle action à n'importe quel niveau du schéma") : une
|
||||
suite déjà présente passe DERRIÈRE la nouvelle action, jamais
|
||||
écrasée. Renvoie False si l'objet, `after_id`, ou `raw_action` sont
|
||||
invalides."""
|
||||
obj = get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from .get_scene_object import get_scene_object
|
||||
from .set_scene_object_collision_rules import set_scene_object_collision_rules
|
||||
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, move_action_in_rules
|
||||
|
||||
|
||||
def move_action_in_trigger(slug, object_id, leaf_id, direction):
|
||||
"""Déplace une action précise d'un cran ("up"/"down", voir
|
||||
screens/rendering/collision_rules.py::move_action_in_rules) dans son
|
||||
déclencheur d'OBJET — même patron qu'append_action_to_trigger."""
|
||||
obj = get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return False
|
||||
rules = resolve_collision_rules(obj)
|
||||
if not move_action_in_rules(rules, leaf_id, direction):
|
||||
return False
|
||||
sanitized = sanitize_collision_rules(rules)
|
||||
set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return True
|
||||
@@ -0,0 +1,20 @@
|
||||
from .get_scene_object import get_scene_object
|
||||
from .set_scene_object_collision_rules import set_scene_object_collision_rules
|
||||
from ..rendering.collision_rules import resolve_collision_rules, sanitize_collision_rules, remove_action_from_rules
|
||||
|
||||
|
||||
def remove_action_from_trigger(slug, object_id, leaf_id):
|
||||
"""Retire une action précise (voir screens/rendering/collision_rules.py
|
||||
::remove_action_from_rules) d'un déclencheur d'OBJET — même patron
|
||||
qu'append_action_to_trigger : UNIQUE chemin d'écriture, partagé par la
|
||||
route HTTP (routes/triggers/triggers.py) et l'agent IA (voir
|
||||
ai/tools.py). Renvoie False si l'objet ou `leaf_id` sont introuvables."""
|
||||
obj = get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
return False
|
||||
rules = resolve_collision_rules(obj)
|
||||
if not remove_action_from_rules(rules, leaf_id):
|
||||
return False
|
||||
sanitized = sanitize_collision_rules(rules)
|
||||
set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return True
|
||||
@@ -0,0 +1,20 @@
|
||||
from .get_screen import get_screen
|
||||
from .set_screen_triggers import set_screen_triggers
|
||||
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
|
||||
from ..rendering.collision_rules import set_then_action_in_rules
|
||||
|
||||
|
||||
def append_action_to_screen_trigger(slug, screen_id, after_id, raw_action):
|
||||
"""Équivalent de screens.append_action_to_trigger (voir scenes/
|
||||
append_action_to_trigger.py) pour un déclencheur D'ÉCRAN plutôt que
|
||||
d'un objet — même chemin d'écriture UNIQUE partagé par la route HTTP
|
||||
(routes/screens/screen_triggers.py) et l'agent IA (voir ai/tools.py)."""
|
||||
screen = get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return False
|
||||
rules = resolve_screen_triggers(screen)
|
||||
if not set_then_action_in_rules(rules, after_id, raw_action):
|
||||
return False
|
||||
sanitized = sanitize_screen_triggers(rules)
|
||||
set_screen_triggers(slug, screen_id, sanitized)
|
||||
return True
|
||||
@@ -60,5 +60,13 @@ def ensure_schema(slug):
|
||||
# d'un jeu créé avant l'existence de cette colonne).
|
||||
conn.execute("ALTER TABLE _screens ADD COLUMN kind TEXT NOT NULL DEFAULT 'jeu_2d'")
|
||||
conn.execute("UPDATE _screens SET kind = ?", (db.get_game_type(slug),))
|
||||
if "_screen_triggers" not in screen_cols:
|
||||
# Déclencheurs D'ÉCRAN (voir screens/rendering/screen_triggers.py) —
|
||||
# section "Déclencheurs de l'écran" de l'éditeur de scène : SANS
|
||||
# objet propriétaire, contrairement à _collision_rules (porté par
|
||||
# un objet, colonne JSON dans _scene_objects.attributes). NULL
|
||||
# (aucune règle) pour tout écran créé avant l'ajout de cette
|
||||
# fonctionnalité, comme pour les autres colonnes ci-dessus.
|
||||
conn.execute("ALTER TABLE _screens ADD COLUMN _screen_triggers TEXT")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from .get_screen import get_screen
|
||||
from .set_screen_triggers import set_screen_triggers
|
||||
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
|
||||
from ..rendering.collision_rules import move_action_in_rules
|
||||
|
||||
|
||||
def move_action_in_screen_trigger(slug, screen_id, leaf_id, direction):
|
||||
"""Équivalent de screens.move_action_in_trigger pour un déclencheur
|
||||
D'ÉCRAN plutôt que d'un objet."""
|
||||
screen = get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return False
|
||||
rules = resolve_screen_triggers(screen)
|
||||
if not move_action_in_rules(rules, leaf_id, direction):
|
||||
return False
|
||||
sanitized = sanitize_screen_triggers(rules)
|
||||
set_screen_triggers(slug, screen_id, sanitized)
|
||||
return True
|
||||
@@ -0,0 +1,19 @@
|
||||
from .get_screen import get_screen
|
||||
from .set_screen_triggers import set_screen_triggers
|
||||
from ..rendering.screen_triggers import resolve_screen_triggers, sanitize_screen_triggers
|
||||
from ..rendering.collision_rules import remove_action_from_rules
|
||||
|
||||
|
||||
def remove_action_from_screen_trigger(slug, screen_id, leaf_id):
|
||||
"""Équivalent de screens.remove_action_from_trigger pour un
|
||||
déclencheur D'ÉCRAN plutôt que d'un objet — même chemin d'écriture
|
||||
UNIQUE partagé par la route HTTP et l'agent IA."""
|
||||
screen = get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return False
|
||||
rules = resolve_screen_triggers(screen)
|
||||
if not remove_action_from_rules(rules, leaf_id):
|
||||
return False
|
||||
sanitized = sanitize_screen_triggers(rules)
|
||||
set_screen_triggers(slug, screen_id, sanitized)
|
||||
return True
|
||||
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from .ensure_schema import ensure_schema
|
||||
|
||||
|
||||
def set_screen_triggers(slug, screen_id, rules):
|
||||
"""Remplace TOUTES les règles "déclencheur d'écran -> action" de cet
|
||||
écran (voir screens/rendering/screen_triggers.py) — même patron que
|
||||
set_scene_object_collision_rules pour un objet, mais stocké
|
||||
directement sur _screens (aucun objet propriétaire)."""
|
||||
ensure_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screens SET _screen_triggers = ? WHERE id = ?",
|
||||
(json.dumps(rules), screen_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user