Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c504ace167
commit
d664ed5637
+6
-2
@@ -54,8 +54,7 @@ from .rendering.personnage_data import resolve_personnage_data, resolve_personna
|
||||
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
|
||||
from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
|
||||
from .rendering.collision_rules import (
|
||||
resolve_collision_rules, sanitize_collision_rules,
|
||||
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
|
||||
resolve_collision_rules, sanitize_collision_rules, set_dialogue_lines_in_rules, set_then_action_in_rules,
|
||||
)
|
||||
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
|
||||
@@ -87,6 +86,11 @@ from .scenes.set_scene_object_collision_rules import set_scene_object_collision_
|
||||
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.build_collision_rule_objects import build_collision_rule_objects
|
||||
from .rendering.trigger_graph import (
|
||||
list_all_triggers, list_all_collision_objects, collect_all_dialogue_lines,
|
||||
list_completable_dialogue_ids,
|
||||
)
|
||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||
|
||||
from .flow.ensure_flow_schema import ensure_flow_schema
|
||||
|
||||
@@ -10,6 +10,7 @@ from ..rendering.personnage_role import resolve_personnage_role
|
||||
from ..rendering.collision_settings import resolve_collision_settings
|
||||
from ..rendering.collision_rules import resolve_collision_rules
|
||||
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||
from ..rendering.trigger_graph import list_completable_dialogue_ids
|
||||
from ..scenes.list_scene_objects import list_scene_objects
|
||||
from ..scenes.render_scene_object import render_scene_object
|
||||
|
||||
@@ -156,18 +157,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# ci-dessus sont déjà indexés par id, jamais par nom.
|
||||
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
||||
|
||||
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais
|
||||
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici
|
||||
# pour que static/js/play/dialogue-box-controller.js puisse retrouver,
|
||||
# à l'exécution d'une action "quete" (collision-rules-controller.js),
|
||||
# le dialogue de la colonne correspondant au STATUT ACTUEL de la
|
||||
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur.
|
||||
quests = db.list_quests(slug)
|
||||
# Déclencheurs "terminés" (voir screens/rendering/collision_rules.py::
|
||||
# "dialogue".mark_completed) : GAME-WIDE, la liste des id de dialogue
|
||||
# que le créateur a marqués comme concluant la partie — nécessaire à
|
||||
# static/js/play/dialogue-box-controller.js::
|
||||
# forgeSyncAllDialoguesCompletionToScorm pour savoir quand TOUS ont
|
||||
# été joués, sans plus aucune notion de quête.
|
||||
completable_dialogue_ids = list_completable_dialogue_ids(slug)
|
||||
|
||||
return {
|
||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||
"data": data, "fields_meta": fields_meta,
|
||||
"variables": variables, "personnage_animations": personnage_animations,
|
||||
"definition_names": definition_names,
|
||||
"quests": quests,
|
||||
"completable_dialogue_ids": completable_dialogue_ids,
|
||||
}
|
||||
|
||||
@@ -1,42 +1,166 @@
|
||||
import json
|
||||
|
||||
# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir
|
||||
# templates/scene_edit.html) : chaque objet de scène (hors le personnage
|
||||
# "joueur" — voir screens/rendering/personnage_role.py — et un "fond",
|
||||
# jamais une cible/source de collision) porte une LISTE de règles
|
||||
# "déclencheur -> action", construites carte par carte dans l'éditeur.
|
||||
from db.dialogue_lines import sanitize_dialogue_lines
|
||||
from screens.flow.constants import CONDITION_OPERATORS
|
||||
from screens.labels.data_operations import DATA_OPERATION_LABELS
|
||||
|
||||
# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js::
|
||||
# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque
|
||||
# objet de scène (hors le personnage "joueur" — voir screens/rendering/
|
||||
# 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) ou "perimetre" (le
|
||||
# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet).
|
||||
# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
|
||||
# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
|
||||
# demande explicite).
|
||||
#
|
||||
# Action (une seule par règle) :
|
||||
# - "quete" : quete_id (id technique d'une quête — voir db/quests/,
|
||||
# choisie/créée via l'assistant "+ Action" qui ouvre
|
||||
# directement l'éditeur de quête, static/js/quests/
|
||||
# quest-editor.js) — une quête inexistante/supprimée
|
||||
# depuis reste une référence orpheline plutôt qu'une
|
||||
# contrainte SQL, comme le reste de ce moteur (voir
|
||||
# evenement_id ci-dessous, même principe).
|
||||
# - "attaque" : joue la pose "attack" du personnage si disponible (voir
|
||||
# personnage-controller.js) — pas de système de combat
|
||||
# (dégâts/PV...) pour l'instant, effet visuel seul.
|
||||
# - "evenement" : evenement_id — réutilise TEL QUEL le système
|
||||
# d'événements personnalisés existant (db.list_custom_events,
|
||||
# window.dispatchGameEvent), la logique de flow d'un écran
|
||||
# document peut donc déjà écouter ce déclenchement.
|
||||
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du
|
||||
# joueur, voir personnage_commands.py) tant que la
|
||||
# collision/le périmètre est actif, puis exécute
|
||||
# `sub_action` (même forme qu'une action, SAUF
|
||||
# "interagir" à nouveau — UN seul niveau d'imbrication,
|
||||
# pas une récursion illimitée) à l'appui de la touche.
|
||||
ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"]
|
||||
TRIGGER_TYPES = ["collision", "perimetre"]
|
||||
DEFAULT_PERIMETRE_PX = 5
|
||||
# Action :
|
||||
# - "dialogue" : la logique n'est plus portée par une "quête" mais
|
||||
# DIRECTEMENT par le déclencheur — cette action EST un
|
||||
# dialogue, pas une référence à autre chose. `dialogue`
|
||||
# (optionnel) : {id, lines} — `id` généré côté client
|
||||
# (voir static/js/scenes/collision-rules-editor.js),
|
||||
# `lines` une liste de répliques/questions (voir
|
||||
# db/dialogue_lines.py) — chaque occurrence de
|
||||
# "Déclencher ce dialogue" (directe, sous "interagir",
|
||||
# ou dans une branche de "condition") porte SON PROPRE
|
||||
# dialogue, édité en ligne dans l'onglet "Déclencheurs".
|
||||
# `mark_completed` (optionnel, bool) : le créateur
|
||||
# marque explicitement ce déclencheur comme "terminé"
|
||||
# une fois ce dialogue joué (voir static/js/play/
|
||||
# dialogue-box-controller.js::
|
||||
# forgeSyncAllDialoguesCompletionToScorm).
|
||||
# - "variable" : modifie une variable globale — MÊME vocabulaire que
|
||||
# l'action "Modifier une variable" du graphe de logique
|
||||
# document (voir screens/data_actions/apply_variable_action.py,
|
||||
# screens/labels/data_operations.py::DATA_OPERATION_LABELS,
|
||||
# réutilisé tel quel plutôt que réinventé) :
|
||||
# `target_variable` (nom), `data_operation` (une clé de
|
||||
# DATA_OPERATION_LABELS), `data_value` (optionnel, absent
|
||||
# pour definir_bool_vrai/definir_bool_faux/basculer_bool
|
||||
# qui n'ont besoin d'aucune valeur). `id` généré côté
|
||||
# client, comme `dialogue.id` — sert à adresser CETTE
|
||||
# feuille précise pour y attacher un "then" plus tard
|
||||
# (voir set_then_action_in_rules).
|
||||
# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir"
|
||||
# du joueur, voir personnage_commands.py) tant que la
|
||||
# collision est active, puis exécute `sub_action` (même
|
||||
# forme qu'une action, SAUF "interagir" à nouveau — UN
|
||||
# seul niveau d'imbrication, pas une récursion illimitée)
|
||||
# à l'appui de la touche.
|
||||
# - "condition" : Si <variable> <opérateur> <valeur> (voir
|
||||
# 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).
|
||||
#
|
||||
# CHAÎNAGE : une feuille ("dialogue" ou "variable") 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
|
||||
# déclencheur pour lui ajouter des actions" (plusieurs actions chaînées,
|
||||
# pas juste remplacer l'action actuelle).
|
||||
#
|
||||
# ("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"]
|
||||
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
|
||||
|
||||
|
||||
_MAX_ACTION_ID_LENGTH = 40
|
||||
# Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||
# data_operations.py::DATA_OPERATIONS) — data_value est toujours omis
|
||||
# pour celles-ci, quoi que le client envoie (même logique que
|
||||
# static/js/scenes/collision-rules-editor.js::COLLISION_NO_VALUE_OPERATIONS,
|
||||
# qui masque le champ correspondant côté formulaire).
|
||||
_NO_VALUE_OPERATIONS = ("definir_bool_vrai", "definir_bool_faux", "basculer_bool")
|
||||
|
||||
|
||||
def _sanitize_dialogue(raw_dialogue):
|
||||
if not isinstance(raw_dialogue, dict):
|
||||
return None
|
||||
dialogue_id = raw_dialogue.get("id")
|
||||
if not isinstance(dialogue_id, str) or not dialogue_id.strip():
|
||||
return None
|
||||
return {
|
||||
"id": dialogue_id.strip()[:_MAX_ACTION_ID_LENGTH],
|
||||
"lines": sanitize_dialogue_lines(raw_dialogue.get("lines")),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_dialogue_action(raw_action):
|
||||
action = {"type": "dialogue"}
|
||||
dialogue = _sanitize_dialogue(raw_action.get("dialogue"))
|
||||
if dialogue:
|
||||
action["dialogue"] = dialogue
|
||||
if raw_action.get("mark_completed") is True:
|
||||
action["mark_completed"] = True
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_variable_action(raw_action):
|
||||
target_variable = raw_action.get("target_variable")
|
||||
if not isinstance(target_variable, str) or not target_variable.strip():
|
||||
return None
|
||||
data_operation = raw_action.get("data_operation")
|
||||
if data_operation not in DATA_OPERATION_LABELS:
|
||||
return None
|
||||
action = {"type": "variable", "target_variable": target_variable.strip(), "data_operation": data_operation}
|
||||
action_id = raw_action.get("id")
|
||||
if isinstance(action_id, str) and action_id.strip():
|
||||
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
|
||||
if data_operation not in _NO_VALUE_OPERATIONS:
|
||||
raw_value = raw_action.get("data_value")
|
||||
if isinstance(raw_value, str) and raw_value.strip():
|
||||
action["data_value"] = raw_value.strip()
|
||||
elif isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool):
|
||||
action["data_value"] = str(raw_value)
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_leaf_action(raw_action, depth=1):
|
||||
"""Sanitize une FEUILLE ("dialogue" ou "variable") 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:
|
||||
return None
|
||||
if not action:
|
||||
return None
|
||||
if depth < _MAX_THEN_CHAIN:
|
||||
then_action = _sanitize_leaf_action(raw_action.get("then"), depth=depth + 1)
|
||||
if then_action:
|
||||
action["then"] = then_action
|
||||
return action
|
||||
|
||||
|
||||
def _sanitize_condition_branch(raw_branch):
|
||||
"""Une branche Vrai/Faux de "condition" : soit None ("ne rien
|
||||
faire", jamais un dict vide), soit une FEUILLE ("dialogue" ou
|
||||
"variable", éventuellement chaînée via "then") — jamais "interagir"/
|
||||
"condition" à nouveau, pour garder la structure bornée (voir maquette
|
||||
validée : chaque branche n'a que ces choix)."""
|
||||
if raw_branch is None:
|
||||
return None
|
||||
return _sanitize_leaf_action(raw_branch)
|
||||
|
||||
|
||||
def _sanitize_action(raw_action, depth=1):
|
||||
@@ -47,31 +171,33 @@ def _sanitize_action(raw_action, depth=1):
|
||||
return None
|
||||
if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH:
|
||||
return None # un seul niveau d'imbrication autorisé
|
||||
action = {"type": action_type}
|
||||
if action_type == "quete":
|
||||
try:
|
||||
action["quete_id"] = int(raw_action.get("quete_id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
elif action_type == "evenement":
|
||||
try:
|
||||
action["evenement_id"] = int(raw_action.get("evenement_id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
elif action_type == "interagir":
|
||||
if action_type in LEAF_ACTION_TYPES:
|
||||
return _sanitize_leaf_action(raw_action)
|
||||
if action_type == "interagir":
|
||||
sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1)
|
||||
if not sub_action:
|
||||
return None
|
||||
action["sub_action"] = sub_action
|
||||
# "attaque" : aucun champ supplémentaire.
|
||||
return action
|
||||
return {"type": "interagir", "sub_action": sub_action}
|
||||
# "condition"
|
||||
variable = (raw_action.get("variable") or "").strip()
|
||||
operateur = raw_action.get("operateur")
|
||||
if not variable or operateur not in CONDITION_OPERATOR_KEYS:
|
||||
return None
|
||||
return {
|
||||
"type": "condition",
|
||||
"variable": variable,
|
||||
"operateur": operateur,
|
||||
"valeur": str(raw_action.get("valeur") if raw_action.get("valeur") is not None else ""),
|
||||
"si_vrai": _sanitize_condition_branch(raw_action.get("si_vrai")),
|
||||
"si_faux": _sanitize_condition_branch(raw_action.get("si_faux")),
|
||||
}
|
||||
|
||||
|
||||
def sanitize_collision_rules(raw_rules):
|
||||
"""Valide/nettoie une liste de règles postée par le client (voir
|
||||
routes/scenes/scene_object_collision_rules.py) — une règle invalide
|
||||
(déclencheur/action inconnus, imbrication trop profonde...) est
|
||||
IGNORÉE plutôt que de faire planter la sauvegarde entière."""
|
||||
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."""
|
||||
if not isinstance(raw_rules, list):
|
||||
return []
|
||||
cleaned = []
|
||||
@@ -84,16 +210,96 @@ def sanitize_collision_rules(raw_rules):
|
||||
action = _sanitize_action(raw_rule.get("action"))
|
||||
if not action:
|
||||
continue
|
||||
rule = {"trigger": trigger, "action": action}
|
||||
if trigger == "perimetre":
|
||||
try:
|
||||
rule["perimetre_px"] = max(1, float(raw_rule.get("perimetre_px", DEFAULT_PERIMETRE_PX)))
|
||||
except (TypeError, ValueError):
|
||||
rule["perimetre_px"] = DEFAULT_PERIMETRE_PX
|
||||
cleaned.append(rule)
|
||||
cleaned.append({"trigger": trigger, "action": action})
|
||||
return cleaned
|
||||
|
||||
|
||||
def _apply_dialogue_lines(action, dialogue_id, lines):
|
||||
if not isinstance(action, dict):
|
||||
return False
|
||||
action_type = action.get("type")
|
||||
if action_type == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
if isinstance(dialogue, dict) and dialogue.get("id") == dialogue_id:
|
||||
action["dialogue"] = {"id": dialogue_id, "lines": lines}
|
||||
return True
|
||||
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
|
||||
if action_type == "variable":
|
||||
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)
|
||||
if action_type == "condition":
|
||||
return (
|
||||
_apply_dialogue_lines(action.get("si_vrai"), dialogue_id, lines)
|
||||
or _apply_dialogue_lines(action.get("si_faux"), dialogue_id, lines)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def set_dialogue_lines_in_rules(rules, dialogue_id, raw_lines):
|
||||
"""Met à jour EN PLACE les répliques du dialogue `dialogue_id`,
|
||||
où qu'il se trouve dans l'arbre d'action d'une des règles (direct,
|
||||
chaîné via "then", sous "interagir", ou dans une branche de
|
||||
"condition") — utilisé par routes/triggers/ pour l'édition en ligne
|
||||
d'un dialogue depuis l'onglet "Déclencheurs". Retourne False sans
|
||||
rien modifier si `dialogue_id` n'existe dans aucune règle (référence
|
||||
orpheline, ex. règle supprimée entre-temps)."""
|
||||
lines = sanitize_dialogue_lines(raw_lines)
|
||||
for rule in rules:
|
||||
if _apply_dialogue_lines(rule.get("action"), dialogue_id, lines):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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":
|
||||
return action.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def _apply_then_action(action, leaf_id, then_action):
|
||||
if not isinstance(action, dict):
|
||||
return False
|
||||
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é
|
||||
action["then"] = then_action
|
||||
return True
|
||||
return _apply_then_action(action.get("then"), leaf_id, then_action)
|
||||
if action_type == "interagir":
|
||||
return _apply_then_action(action.get("sub_action"), leaf_id, then_action)
|
||||
if action_type == "condition":
|
||||
return (
|
||||
_apply_then_action(action.get("si_vrai"), leaf_id, then_action)
|
||||
or _apply_then_action(action.get("si_faux"), leaf_id, then_action)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
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)."""
|
||||
then_action = _sanitize_leaf_action(raw_then_action)
|
||||
if not then_action:
|
||||
return False
|
||||
for rule in rules:
|
||||
if _apply_then_action(rule.get("action"), leaf_id, then_action):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
|
||||
# templates/scene_edit.html, screens/scenes/add_scene_object.py kind=
|
||||
# "dialogue_box") : affiche AUTOMATIQUEMENT, en jeu, la réplique en cours
|
||||
# d'une quête déclenchée par une règle de collision "quete" (voir
|
||||
# d'un dialogue déclenché par une règle de collision "dialogue" (voir
|
||||
# static/js/play/dialogue-box-controller.js) — header (nom du
|
||||
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
|
||||
# entièrement stylisable par l'auteur.
|
||||
|
||||
@@ -11,10 +11,10 @@ _EXCLUDED_KINDS = {"dialogue_box"}
|
||||
def list_named_scene_object_names(slug):
|
||||
"""Noms distincts de tous les objets de scène nommés (voir "ℹ️
|
||||
Informations", templates/scene_edit.html) — n'importe quel kind
|
||||
(personnage, décor, fond), toutes scènes 2D du jeu confondues : une
|
||||
quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre
|
||||
en scène un objet posé sur une autre scène que celle où la règle de
|
||||
collision se déclenche. Ordre alphabétique, jamais de doublon."""
|
||||
(personnage, décor, fond), toutes scènes 2D du jeu confondues : un
|
||||
déclencheur est GAME-WIDE, son dialogue peut donc mettre en scène un
|
||||
objet posé sur une autre scène que celle où la règle de collision se
|
||||
déclenche. Ordre alphabétique, jamais de doublon."""
|
||||
names = set()
|
||||
for s in list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
def _game_objects_with_thumbnails(slug, screens_mod):
|
||||
"""Objets de TOUTES les scènes "jeu_2d" du jeu (contrairement à
|
||||
screens.list_scene_objects, qui ne prend qu'un screen_id) —
|
||||
rendered_html ajouté comme l'exigent screens.build_collision_rule_objects
|
||||
(voir routes/scenes/scene_edit_view.py pour le même patron, une seule
|
||||
scène)."""
|
||||
result = []
|
||||
for s in screens_mod.list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
continue
|
||||
objects = screens_mod.list_scene_objects(slug, s["id"])
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens_mod.render_scene_object(o)
|
||||
o["screen_id"] = s["id"]
|
||||
o["screen_name"] = s["name"]
|
||||
result.extend(objects)
|
||||
return result
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
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"."""
|
||||
chain = []
|
||||
node = action
|
||||
while isinstance(node, dict) and node.get("type") in ("dialogue", "variable"):
|
||||
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"):
|
||||
return None
|
||||
return {"chain": _describe_chain(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: [...]}."""
|
||||
if not isinstance(action, dict):
|
||||
return {"has_interagir": has_interagir, "chain": [], "condition": None}
|
||||
action_type = action.get("type")
|
||||
if action_type in ("dialogue", "variable"):
|
||||
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)
|
||||
if action_type == "condition":
|
||||
return {
|
||||
"has_interagir": has_interagir, "chain": [],
|
||||
"condition": {
|
||||
"variable": action.get("variable"), "operateur": action.get("operateur"),
|
||||
"valeur": action.get("valeur"),
|
||||
"si_vrai": _branch_info(action.get("si_vrai")),
|
||||
"si_faux": _branch_info(action.get("si_faux")),
|
||||
},
|
||||
}
|
||||
return {"has_interagir": has_interagir, "chain": [], "condition": None}
|
||||
|
||||
|
||||
def list_all_triggers(slug):
|
||||
"""TOUS les déclencheurs du jeu, un par occurrence de "À la collision"
|
||||
posée sur un objet de scène — remplace l'ancienne recherche inversée
|
||||
par quête : plus de filtrage par id, chaque déclencheur affiche
|
||||
directement sa propre logique (voir onglet "Déclencheurs",
|
||||
static/js/triggers/trigger-editor.js)."""
|
||||
import screens as screens_mod
|
||||
|
||||
objects = _game_objects_with_thumbnails(slug, screens_mod)
|
||||
rule_objects = screens_mod.build_collision_rule_objects(objects)
|
||||
|
||||
result = []
|
||||
for obj in rule_objects:
|
||||
for rule in obj["rules"]:
|
||||
info = _resolve_action_trigger(rule["action"])
|
||||
result.append({
|
||||
"object_id": obj["id"],
|
||||
"object_name": obj["name"],
|
||||
"thumbnail_html": obj["thumbnail_html"],
|
||||
"has_interagir": info["has_interagir"],
|
||||
"trigger": rule["trigger"],
|
||||
"condition": info["condition"],
|
||||
"chain": info["chain"],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _collect_dialogue_lines_from_action(action, acc):
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
action_type = action.get("type")
|
||||
if action_type == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
if isinstance(dialogue, dict):
|
||||
acc.append(dialogue.get("lines") or [])
|
||||
_collect_dialogue_lines_from_action(action.get("then"), acc)
|
||||
elif action_type == "variable":
|
||||
_collect_dialogue_lines_from_action(action.get("then"), acc)
|
||||
elif action_type == "interagir":
|
||||
_collect_dialogue_lines_from_action(action.get("sub_action"), acc)
|
||||
elif action_type == "condition":
|
||||
_collect_dialogue_lines_from_action(action.get("si_vrai"), acc)
|
||||
_collect_dialogue_lines_from_action(action.get("si_faux"), acc)
|
||||
|
||||
|
||||
def collect_all_dialogue_lines(slug):
|
||||
"""Toutes les répliques/questions de TOUS les dialogues embarqués dans
|
||||
les règles de collision de TOUS les objets de scène du jeu (voir
|
||||
screens/rendering/collision_rules.py::"dialogue".dialogue), y compris
|
||||
au fil d'une chaîne "then" — une liste de listes de lignes (une par
|
||||
dialogue trouvé), jamais fusionnée ici. Utilisée pour calculer le
|
||||
score max possible du quiz à l'export SCORM (voir
|
||||
publish/build_scorm_package.py)."""
|
||||
import screens as screens_mod
|
||||
|
||||
acc = []
|
||||
for s in screens_mod.list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
continue
|
||||
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)
|
||||
return acc
|
||||
|
||||
|
||||
def _collect_completable_ids_from_action(action, acc):
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
action_type = action.get("type")
|
||||
if action_type == "dialogue":
|
||||
dialogue = action.get("dialogue")
|
||||
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":
|
||||
_collect_completable_ids_from_action(action.get("then"), acc)
|
||||
elif action_type == "interagir":
|
||||
_collect_completable_ids_from_action(action.get("sub_action"), acc)
|
||||
elif action_type == "condition":
|
||||
_collect_completable_ids_from_action(action.get("si_vrai"), acc)
|
||||
_collect_completable_ids_from_action(action.get("si_faux"), acc)
|
||||
|
||||
|
||||
def list_completable_dialogue_ids(slug):
|
||||
"""Tous les id de dialogue du jeu dont l'action porte
|
||||
`mark_completed` (voir screens/rendering/collision_rules.py), y
|
||||
compris au fil d'une chaîne "then" — utilisée par static/js/play/
|
||||
dialogue-box-controller.js::forgeSyncAllDialoguesCompletionToScorm
|
||||
pour savoir quand la partie est terminée (TOUS joués), sans plus
|
||||
aucune notion de quête."""
|
||||
import screens as screens_mod
|
||||
|
||||
acc = []
|
||||
for s in screens_mod.list_screens(slug):
|
||||
if s["kind"] != "jeu_2d":
|
||||
continue
|
||||
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)
|
||||
return acc
|
||||
|
||||
|
||||
def list_all_collision_objects(slug):
|
||||
"""Catalogue de TOUS les objets éligibles à une règle de collision,
|
||||
toutes scènes "jeu_2d" confondues — pour peupler le sélecteur d'objet
|
||||
de la modale d'ajout de déclencheur (voir routes/triggers/)."""
|
||||
import screens as screens_mod
|
||||
|
||||
objects = _game_objects_with_thumbnails(slug, screens_mod)
|
||||
rule_objects = screens_mod.build_collision_rule_objects(objects)
|
||||
return [
|
||||
{"id": o["id"], "name": o["name"], "kind": o["kind"], "thumbnail_html": o["thumbnail_html"]}
|
||||
for o in rule_objects
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
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), par l'endpoint AJAX scene_collision_rule_objects.py
|
||||
(appelé après ajout/suppression/changement de rôle ou de nom, voir
|
||||
static/js/scenes/scene-editor.js) ET par le listage de TOUS les
|
||||
déclencheurs du jeu (screens/rendering/trigger_graph.py) — vit dans
|
||||
screens/ (pas routes/) pour être appelable depuis les deux.
|
||||
|
||||
`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
|
||||
@@ -47,7 +47,7 @@ def render_scene_object(obj):
|
||||
# body/footer sont remplis EN JEU (data-dialogue-role sert de
|
||||
# sélecteur), masqué par défaut au jeu (voir static/style.css,
|
||||
# `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de
|
||||
# collision "quete" ait un dialogue à afficher. Toujours VISIBLE
|
||||
# collision "dialogue" ait un dialogue à afficher. Toujours VISIBLE
|
||||
# dans l'éditeur (avec un texte d'exemple) pour pouvoir le
|
||||
# positionner/styliser.
|
||||
style = resolve_dialogue_box_style(obj)
|
||||
|
||||
@@ -9,8 +9,8 @@ def set_scene_object_name(slug, object_id, name):
|
||||
partout ailleurs, mais jamais éditable avant le panneau "ℹ️
|
||||
Informations", templates/scene_edit.html). Pour un personnage, ce nom
|
||||
est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue
|
||||
de quête (voir screens/rendering/scene_object_names.py, static/js/
|
||||
quests/quest-editor.js)."""
|
||||
d'un déclencheur (voir screens/rendering/scene_object_names.py,
|
||||
static/js/triggers/trigger-editor.js)."""
|
||||
ensure_scene_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id))
|
||||
|
||||
Reference in New Issue
Block a user