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
@@ -68,13 +68,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation)
|
||||
- name: Tests JS (node:test — logique pure de static/js/play/, et l'assistant déclencheurs de static/js/scenes/+static/js/triggers/, voir le plan de modularisation)
|
||||
run: |
|
||||
docker build -f - -t forge-test-js:${{ gitea.sha }} . <<'DOCKERFILE'
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY static/js/play/ static/js/play/
|
||||
RUN node --test static/js/play/__tests__/*.test.js
|
||||
COPY static/js/scenes/ static/js/scenes/
|
||||
COPY static/js/triggers/ static/js/triggers/
|
||||
RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
||||
DOCKERFILE
|
||||
|
||||
build-and-push:
|
||||
|
||||
@@ -48,6 +48,8 @@ _srv_out.txt
|
||||
_srv_err.txt
|
||||
_diag_out.txt
|
||||
_diag_err.txt
|
||||
server_out.log
|
||||
server_err.log
|
||||
|
||||
# Documents internes/business (cadrage produit...) et état local de
|
||||
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
|
||||
|
||||
+64
-11
@@ -37,18 +37,46 @@ _SYSTEM_PROMPT = (
|
||||
"— jamais pour un personnage ou un sprite animé, la génération "
|
||||
"d'image n'est pas fiable pour ça. "
|
||||
"\n\n"
|
||||
"CE QUE TU NE PEUX PAS FAIRE : tes outils ne pilotent QUE la logique "
|
||||
"\"déclencheur de collision\" d'un objet de scène (dialogue, "
|
||||
"variable, interagir, condition). Forge Engine a un AUTRE système, "
|
||||
"séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, les "
|
||||
"minuteurs, jouer un son, une animation de sprite — tu n'as AUCUN "
|
||||
"CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique "
|
||||
"\"déclencheur -> action\" d'un objet de scène OU de l'écran entier "
|
||||
"(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE "
|
||||
"système, séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, "
|
||||
"les minuteurs récurrents, une animation de sprite — tu n'as AUCUN "
|
||||
"outil pour ça. Si une demande a besoin de cette partie-là (\"passer "
|
||||
"à l'écran suivant\", \"jouer un son\", \"après 5 secondes\"...), fais "
|
||||
"quand même tout ce que tes outils permettent, puis DIS CLAIREMENT "
|
||||
"dans ta réponse texte ce que tu n'as pas pu faire et pourquoi "
|
||||
"(cette partie doit être ajoutée à la main dans l'onglet \"Flow\") — "
|
||||
"ne t'acharne JAMAIS à répéter des appels d'outils pour une chose "
|
||||
"qu'aucun outil ne permet, ça n'aboutira jamais."
|
||||
"à l'écran suivant\", \"après 5 secondes\"...), fais quand même tout "
|
||||
"ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse "
|
||||
"texte ce que tu n'as pas pu faire et pourquoi (cette partie doit "
|
||||
"être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS "
|
||||
"à répéter des appels d'outils pour une chose qu'aucun outil ne "
|
||||
"permet, ça n'aboutira jamais."
|
||||
"\n\n"
|
||||
"DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : "
|
||||
"\"collision\" (contact avec le personnage \"joueur\" — a besoin d'un "
|
||||
"joueur, voir JOUEUR ET PNJ), \"clic\" (l'objet est cliqué/touché, "
|
||||
"aucun joueur requis — typique d'un panneau, un bouton, un objet "
|
||||
"d'interface statique), \"survol\" (le pointeur survole l'objet, "
|
||||
"aucun joueur requis — typique d'une info contextuelle affichée sans "
|
||||
"action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS "
|
||||
"objet requis) : \"affichage\" — se déclenche dès que l'écran "
|
||||
"apparaît, pour une narration/cinématique d'ouverture. "
|
||||
"RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel "
|
||||
"déclencheur utiliser pour un élément donné (ex. \"ajoute un panneau "
|
||||
"d'information\" sans dire si c'est au clic, au survol, ou dès "
|
||||
"l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta "
|
||||
"réponse texte avant d'agir. "
|
||||
"NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, "
|
||||
"chaînables via \"then\" comme dialogue/variable) : \"surbrillance\" "
|
||||
"(met un objet en valeur, ex. pour guider l'attention du joueur vers "
|
||||
"la suite), \"visibilite\" (affiche/masque un objet, ex. débloquer un "
|
||||
"élément), \"son\" (effet sonore ponctuel), \"video\" (joue une vidéo "
|
||||
"de \"Mes assets\", en plein écran ou en incrustation — bloque la "
|
||||
"suite de la chaîne jusqu'à la fin, comme un dialogue), \"indication\" "
|
||||
"(bulle de texte courte près d'un objet, pour un conseil ponctuel — "
|
||||
"PAS pour une réplique de personnage, utilise \"dialogue\" pour ça), "
|
||||
"\"attendre\" (suspend la chaîne \"then\" pendant data_value SECONDES "
|
||||
"avant de continuer — utile pour laisser un temps de lecture après "
|
||||
"une narration à l'affichage de l'écran, avant d'enchaîner sur un "
|
||||
"dialogue/une autre action)."
|
||||
"\n\n"
|
||||
"ÉTAT DE LA SCÈNE : le message système de chaque tour te donne les "
|
||||
"dimensions de la caméra, la liste des objets déjà posés sur CET "
|
||||
@@ -56,6 +84,14 @@ _SYSTEM_PROMPT = (
|
||||
"d'agir plutôt que de deviner (ne recrée jamais un objet ou une "
|
||||
"variable qui existe déjà, corrige/complète l'existant avec "
|
||||
"set_object_geometry/set_object_name/set_object_role/set_collision_rules). "
|
||||
"Pour retirer ou réordonner UNE action précise d'une chaîne déjà posée "
|
||||
"(sans reconstruire toute la règle), utilise remove_trigger_action/"
|
||||
"move_trigger_action (objet) ou remove_screen_trigger_action/"
|
||||
"move_screen_trigger_action (écran) avec le leaf_id concerné. "
|
||||
"append_action_to_trigger/append_action_to_screen_trigger peuvent "
|
||||
"INSÉRER une action à N'IMPORTE QUEL niveau d'une chaîne (pas "
|
||||
"seulement à la toute fin) : after_id désigne le bloc juste AVANT "
|
||||
"l'endroit où insérer."
|
||||
"\n\n"
|
||||
"CAMÉRA ET POSITIONNEMENT : les dimensions indiquées sont la zone "
|
||||
"VISIBLE (coin haut-gauche à (0,0)) — place tout objet important "
|
||||
@@ -85,6 +121,13 @@ _SYSTEM_PROMPT = (
|
||||
"personnage avec role=\"joueur\" (set_object_role) — jamais deux, "
|
||||
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
|
||||
"demandé. "
|
||||
"RÈGLE IMPORTANTE : si le créateur ne précise pas comment un "
|
||||
"déclencheur \"collision\" doit se comporter, demande-lui si "
|
||||
"l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le "
|
||||
"joueur doit d'abord APPUYER SUR UNE TOUCHE (action \"interagir\", "
|
||||
"qui affiche \"Appuie sur [touche]\" tant que le contact dure) avant "
|
||||
"que l'action ne se déclenche — ne suppose jamais l'un ou l'autre "
|
||||
"toi-même sur une scène avec joueur+collision."
|
||||
"\n\n"
|
||||
"QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une "
|
||||
"donnée consultée plus tard (progression, un choix du joueur, un "
|
||||
@@ -161,6 +204,16 @@ def _describe_scene_state(slug, screen_id):
|
||||
desc += " [déclencheur déjà configuré]"
|
||||
lines.append(desc)
|
||||
|
||||
screen_triggers = screens.resolve_screen_triggers(screen)
|
||||
if screen_triggers:
|
||||
lines.append(
|
||||
f"{len(screen_triggers)} déclencheur(s) D'ÉCRAN déjà configuré(s) sur cet écran "
|
||||
"(voir set_screen_triggers) — relis-les avant d'en ajouter un nouveau plutôt que "
|
||||
"de dupliquer une narration d'ouverture déjà posée."
|
||||
)
|
||||
else:
|
||||
lines.append("Aucun déclencheur d'écran (narration à l'affichage) configuré pour l'instant.")
|
||||
|
||||
variables = db.list_global_variables(slug)
|
||||
if variables:
|
||||
lines.append("Variables globales déjà existantes dans ce jeu (jamais en recréer une du même nom) :")
|
||||
|
||||
+189
-10
@@ -28,8 +28,12 @@ import auth
|
||||
import db
|
||||
import screens
|
||||
from db.dialogue_lines import QUESTION_REWARD_TYPES
|
||||
from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS
|
||||
from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS, TRIGGER_TYPES
|
||||
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
|
||||
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
|
||||
from .scenario_client import generate_image_url
|
||||
|
||||
_CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS)
|
||||
@@ -76,7 +80,30 @@ _LEAF_ACTION_SCHEMA = {
|
||||
"mark_completed": {"type": "boolean"},
|
||||
"target_variable": {"type": "string", "description": "Pour type=variable : nom d'une variable globale existante."},
|
||||
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
|
||||
"data_value": {"type": "string", "description": "Absent pour definir_bool_vrai/definir_bool_faux/basculer_bool."},
|
||||
"data_value": {"type": "string", "description": "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. Pour type=attendre : nombre de SECONDES à attendre avant \"then\" (ex. \"2\", \"1.5\")."},
|
||||
"object_id": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Pour type=surbrillance/visibilite/indication : id de l'objet CIBLÉ par cette "
|
||||
"action — n'importe quel objet de l'écran, pas forcément celui qui porte le "
|
||||
"déclencheur (ex. le joueur touche un interrupteur -> indication près d'une porte)."
|
||||
),
|
||||
},
|
||||
"valeur": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"Pour type=surbrillance : une valeur parmi {sorted(SURBRILLANCE_LABELS)!r}. "
|
||||
f"Pour type=visibilite : une valeur parmi {sorted(ELEMENT_VISIBILITY_LABELS)!r}. "
|
||||
"Pour type=condition (voir _ACTION_SCHEMA) : la valeur littérale à comparer."
|
||||
),
|
||||
},
|
||||
"asset_url": {
|
||||
"type": "string",
|
||||
"description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".",
|
||||
},
|
||||
"mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."},
|
||||
"texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."},
|
||||
"duree_ms": {"type": "integer", "description": "Pour type=indication : durée d'affichage en ms (optionnel)."},
|
||||
"then": {"description": "Feuille suivante (même forme), récursif."},
|
||||
},
|
||||
"required": ["type"],
|
||||
@@ -93,11 +120,16 @@ _ACTION_SCHEMA = {
|
||||
"target_variable": {"type": "string"},
|
||||
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
|
||||
"data_value": {"type": "string"},
|
||||
"then": {"description": "Feuille suivante (dialogue/variable), récursif."},
|
||||
"object_id": _LEAF_ACTION_SCHEMA["properties"]["object_id"],
|
||||
"valeur": _LEAF_ACTION_SCHEMA["properties"]["valeur"],
|
||||
"asset_url": _LEAF_ACTION_SCHEMA["properties"]["asset_url"],
|
||||
"mode": _LEAF_ACTION_SCHEMA["properties"]["mode"],
|
||||
"texte": _LEAF_ACTION_SCHEMA["properties"]["texte"],
|
||||
"duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"],
|
||||
"then": {"description": "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif."},
|
||||
"sub_action": {"description": "Pour type=interagir : une action (pas \"interagir\" à nouveau)."},
|
||||
"variable": {"type": "string", "description": "Pour type=condition."},
|
||||
"operateur": {"type": "string", "enum": _CONDITION_OPERATORS},
|
||||
"valeur": {"type": "string"},
|
||||
"si_vrai": {"description": "Pour type=condition : null ou une feuille."},
|
||||
"si_faux": {"description": "Pour type=condition : null ou une feuille."},
|
||||
},
|
||||
@@ -196,8 +228,13 @@ TOOLS = [
|
||||
"name": "set_collision_rules",
|
||||
"description": (
|
||||
"Remplace TOUTES les règles \"déclencheur -> action\" d'un objet. "
|
||||
"Sanitizé côté serveur (screens.sanitize_collision_rules) : toute "
|
||||
"valeur invalide est silencieusement retirée plutôt que rejetée."
|
||||
"Déclencheurs disponibles : \"collision\" (contact avec le personnage "
|
||||
"\"joueur\" — voir set_object_role), \"clic\" (l'objet est cliqué/touché, "
|
||||
"aucun joueur requis), \"survol\" (le pointeur survole l'objet, aucun "
|
||||
"joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : "
|
||||
"toute valeur invalide est silencieusement retirée plutôt que rejetée — "
|
||||
"compare le nombre de `rules` renvoyées dans le résultat à ce que tu as "
|
||||
"envoyé pour détecter un retrait silencieux."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
@@ -208,7 +245,7 @@ TOOLS = [
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trigger": {"type": "string"},
|
||||
"trigger": {"type": "string", "enum": list(TRIGGER_TYPES)},
|
||||
"action": _ACTION_SCHEMA,
|
||||
},
|
||||
"required": ["trigger", "action"],
|
||||
@@ -218,6 +255,33 @@ TOOLS = [
|
||||
"required": ["object_id", "rules"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_screen_triggers",
|
||||
"description": (
|
||||
"Remplace TOUTES les règles \"déclencheur d'écran -> action\" de l'écran "
|
||||
"en cours — un déclencheur d'ÉCRAN se déclenche SANS avoir besoin d'un "
|
||||
"objet (narration/cinématique dès l'affichage). Seul déclencheur : "
|
||||
"\"affichage\" (à l'affichage de l'écran). Même sanitisation que "
|
||||
"set_collision_rules."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trigger": {"type": "string", "enum": list(TRIGGER_TYPES_SCREEN)},
|
||||
"action": _ACTION_SCHEMA,
|
||||
},
|
||||
"required": ["trigger", "action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["rules"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_generated_image",
|
||||
"description": (
|
||||
@@ -238,9 +302,12 @@ TOOLS = [
|
||||
{
|
||||
"name": "append_action_to_trigger",
|
||||
"description": (
|
||||
"Ajoute une action à la SUITE d'une chaîne déjà posée sur un "
|
||||
"déclencheur existant, sans reconstruire toute la règle — "
|
||||
"after_id désigne la dernière feuille de la chaîne."
|
||||
"Insère une action JUSTE APRÈS un bloc précis d'une chaîne déjà "
|
||||
"posée sur un déclencheur existant, sans reconstruire toute la "
|
||||
"règle — after_id peut désigner N'IMPORTE QUEL bloc de la "
|
||||
"chaîne, pas forcément le dernier : une suite déjà présente "
|
||||
"après ce bloc passe derrière la nouvelle action plutôt que "
|
||||
"d'être remplacée."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
@@ -252,6 +319,81 @@ TOOLS = [
|
||||
"required": ["object_id", "after_id", "action"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "append_action_to_screen_trigger",
|
||||
"description": (
|
||||
"Équivalent d'append_action_to_trigger pour un déclencheur D'ÉCRAN "
|
||||
"(voir set_screen_triggers), sur l'écran en cours — after_id peut "
|
||||
"désigner N'IMPORTE QUEL bloc de la chaîne, pas forcément le "
|
||||
"dernier."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"after_id": {"type": "string"},
|
||||
"action": _LEAF_ACTION_SCHEMA,
|
||||
},
|
||||
"required": ["after_id", "action"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "remove_trigger_action",
|
||||
"description": (
|
||||
"Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où "
|
||||
"qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne "
|
||||
"\"then\", sous-action d'un \"interagir\", ou branche d'une "
|
||||
"\"condition\"). Si c'était la SEULE action de la règle, la règle "
|
||||
"entière disparaît ; si elle avait une suite \"then\", celle-ci "
|
||||
"prend sa place."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"leaf_id": {"type": "string"},
|
||||
},
|
||||
"required": ["object_id", "leaf_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "remove_screen_trigger_action",
|
||||
"description": "Équivalent de remove_trigger_action pour un déclencheur D'ÉCRAN.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"leaf_id": {"type": "string"}},
|
||||
"required": ["leaf_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "move_trigger_action",
|
||||
"description": (
|
||||
"Déplace une action précise (leaf_id) d'un cran vers le haut "
|
||||
"(\"up\") ou le bas (\"down\") DANS SA PROPRE CHAÎNE \"then\", pour "
|
||||
"un déclencheur d'OBJET — jamais au-delà de sa branche de "
|
||||
"condition/sous-action interagir."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"leaf_id": {"type": "string"},
|
||||
"direction": {"type": "string", "enum": ["up", "down"]},
|
||||
},
|
||||
"required": ["object_id", "leaf_id", "direction"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "move_screen_trigger_action",
|
||||
"description": "Équivalent de move_trigger_action pour un déclencheur D'ÉCRAN.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leaf_id": {"type": "string"},
|
||||
"direction": {"type": "string", "enum": ["up", "down"]},
|
||||
},
|
||||
"required": ["leaf_id", "direction"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -376,6 +518,37 @@ def _dispatch_append_action_to_trigger(slug, screen_id, user_id, object_id, afte
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_set_screen_triggers(slug, screen_id, user_id, rules):
|
||||
sanitized = screens.sanitize_screen_triggers(rules)
|
||||
screens.set_screen_triggers(slug, screen_id, sanitized)
|
||||
return {"ok": True, "rules": sanitized}
|
||||
|
||||
|
||||
def _dispatch_append_action_to_screen_trigger(slug, screen_id, user_id, after_id, action):
|
||||
ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_remove_trigger_action(slug, screen_id, user_id, object_id, leaf_id):
|
||||
ok = screens.remove_action_from_trigger(slug, object_id, leaf_id)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_remove_screen_trigger_action(slug, screen_id, user_id, leaf_id):
|
||||
ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_move_trigger_action(slug, screen_id, user_id, object_id, leaf_id, direction):
|
||||
ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_move_screen_trigger_action(slug, screen_id, user_id, leaf_id, direction):
|
||||
ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt):
|
||||
"""Scenario -> "Mes assets" (auth.save_user_asset, source="ia") ->
|
||||
posée sur l'écran via LE MÊME chemin que Phase 1
|
||||
@@ -401,6 +574,12 @@ _DISPATCH = {
|
||||
"create_global_variable": _dispatch_create_global_variable,
|
||||
"set_collision_rules": _dispatch_set_collision_rules,
|
||||
"append_action_to_trigger": _dispatch_append_action_to_trigger,
|
||||
"set_screen_triggers": _dispatch_set_screen_triggers,
|
||||
"append_action_to_screen_trigger": _dispatch_append_action_to_screen_trigger,
|
||||
"remove_trigger_action": _dispatch_remove_trigger_action,
|
||||
"remove_screen_trigger_action": _dispatch_remove_screen_trigger_action,
|
||||
"move_trigger_action": _dispatch_move_trigger_action,
|
||||
"move_screen_trigger_action": _dispatch_move_screen_trigger_action,
|
||||
"add_generated_image": _dispatch_add_generated_image,
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -37,6 +37,11 @@ from .list_user_assets import list_user_assets
|
||||
from .get_user_asset import get_user_asset
|
||||
from .delete_user_asset import delete_user_asset
|
||||
from .save_user_asset import save_user_asset
|
||||
from .user_asset_kind import user_asset_kind
|
||||
from .validate_audio_duration import validate_audio_duration, MAX_AUDIO_SECONDS
|
||||
from .validate_video_duration import validate_video_duration, MAX_VIDEO_SECONDS
|
||||
from .image_dimensions import image_dimensions
|
||||
from .update_user_asset_scene_kind import update_user_asset_scene_kind
|
||||
|
||||
__all__ = [
|
||||
"users_db_path", "ensure_users_schema", "is_first_user",
|
||||
@@ -54,5 +59,8 @@ __all__ = [
|
||||
"update_profile", "count_admins", "delete_user",
|
||||
"update_email", "EmailUpdateError",
|
||||
"ensure_user_assets_schema", "user_assets_dir", "create_user_asset",
|
||||
"list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset",
|
||||
"list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset", "user_asset_kind",
|
||||
"validate_audio_duration", "MAX_AUDIO_SECONDS",
|
||||
"validate_video_duration", "MAX_VIDEO_SECONDS",
|
||||
"image_dimensions", "update_user_asset_scene_kind",
|
||||
]
|
||||
|
||||
@@ -2,17 +2,24 @@ from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
|
||||
|
||||
def create_user_asset(user_id, filename, original_name=None, source="upload"):
|
||||
def create_user_asset(user_id, filename, original_name=None, source="upload", scene_kind=None):
|
||||
"""Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur
|
||||
disque (voir user_assets_dir.py) — le fichier lui-même est écrit par
|
||||
l'appelant (route d'upload, ou plus tard la génération IA), cette
|
||||
fonction ne fait que la partie base de données, comme
|
||||
db.create_global_variable pour le reste du moteur."""
|
||||
db.create_global_variable pour le reste du moteur.
|
||||
|
||||
scene_kind ("decor"/"fond", images uniquement — voir
|
||||
routes/scenes/scene_edit_view.py) : décide comment un CLIC sur la
|
||||
tuile pose l'objet (screens/scenes/add_scene_object.py), demande
|
||||
explicite "un bouton d'import séparé pour les images de fond et les
|
||||
objets" — sans distinction, toute image "Mes assets" ne pouvait être
|
||||
posée qu'en "decor", jamais comme fond."""
|
||||
ensure_user_assets_schema()
|
||||
conn = connect()
|
||||
conn.execute(
|
||||
"INSERT INTO _user_assets (user_id, filename, original_name, source) VALUES (?, ?, ?, ?)",
|
||||
(user_id, filename, original_name, source),
|
||||
"INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)",
|
||||
(user_id, filename, original_name, source, scene_kind),
|
||||
)
|
||||
asset_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
|
||||
@@ -19,5 +19,16 @@ def ensure_user_assets_schema():
|
||||
);
|
||||
"""
|
||||
)
|
||||
asset_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_user_assets)").fetchall()}
|
||||
if "scene_kind" not in asset_cols:
|
||||
# Demande explicite : "un bouton d'import séparé pour les images
|
||||
# de fond et les objets" — une image "Mes assets" est posée sur
|
||||
# la scène TOUJOURS en kind="decor" jusqu'ici (voir
|
||||
# screens/scenes/add_scene_object.py), impossible à utiliser comme
|
||||
# "fond" (position/empilement différents, voir add_scene_object.py).
|
||||
# NULL pour un son/une vidéo (n'a pas de sens) OU une image
|
||||
# importée AVANT ce réglage (retombe sur "decor", comportement
|
||||
# inchangé — voir routes/scenes/scene_edit_view.py).
|
||||
conn.execute("ALTER TABLE _user_assets ADD COLUMN scene_kind TEXT")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import re
|
||||
import struct
|
||||
|
||||
|
||||
def image_dimensions(content):
|
||||
"""Largeur/hauteur RÉELLES (en pixels) d'une image, lues directement
|
||||
dans ses en-têtes — SANS dépendance externe (Pillow serait la solution
|
||||
habituelle, mais un simple parsing des formats courants suffit ici et
|
||||
évite d'ajouter une dépendance binaire lourde juste pour ça). Renvoie
|
||||
None si le format n'est pas reconnu (ex. SVG vectoriel sans taille
|
||||
fixe) — l'appelant retombe alors sur les valeurs par défaut du schéma
|
||||
(voir screens/scenes/add_scene_object.py)."""
|
||||
if content[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
if len(content) >= 24:
|
||||
width, height = struct.unpack(">II", content[16:24])
|
||||
return width, height
|
||||
return None
|
||||
if content[:6] in (b"GIF87a", b"GIF89a"):
|
||||
if len(content) >= 10:
|
||||
width, height = struct.unpack("<HH", content[6:10])
|
||||
return width, height
|
||||
return None
|
||||
if content[:2] == b"BM":
|
||||
if len(content) >= 26:
|
||||
width, height = struct.unpack("<ii", content[18:26])
|
||||
return abs(width), abs(height)
|
||||
return None
|
||||
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return _webp_dimensions(content)
|
||||
if content[:2] == b"\xff\xd8":
|
||||
return _jpeg_dimensions(content)
|
||||
return None
|
||||
|
||||
|
||||
def _webp_dimensions(content):
|
||||
chunk = content[12:16]
|
||||
if chunk == b"VP8X" and len(content) >= 30:
|
||||
width = 1 + (content[24] | (content[25] << 8) | (content[26] << 16))
|
||||
height = 1 + (content[27] | (content[28] << 8) | (content[29] << 16))
|
||||
return width, height
|
||||
if chunk == b"VP8 " and len(content) >= 30:
|
||||
width, height = struct.unpack("<HH", content[26:30])
|
||||
return width & 0x3FFF, height & 0x3FFF
|
||||
if chunk == b"VP8L" and len(content) >= 25:
|
||||
b = content[21:25]
|
||||
bits = b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)
|
||||
width = (bits & 0x3FFF) + 1
|
||||
height = ((bits >> 14) & 0x3FFF) + 1
|
||||
return width, height
|
||||
return None
|
||||
|
||||
|
||||
def _jpeg_dimensions(content):
|
||||
i = 2
|
||||
n = len(content)
|
||||
while i + 9 < n:
|
||||
if content[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = content[i + 1]
|
||||
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
||||
i += 2
|
||||
continue
|
||||
if marker == 0xD9:
|
||||
break
|
||||
seg_len = struct.unpack(">H", content[i + 2:i + 4])[0]
|
||||
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
||||
height, width = struct.unpack(">HH", content[i + 5:i + 9])
|
||||
return width, height
|
||||
i += 2 + seg_len
|
||||
return None
|
||||
@@ -9,7 +9,7 @@ def list_user_assets(user_id):
|
||||
ensure_user_assets_schema()
|
||||
conn = connect()
|
||||
rows = conn.execute(
|
||||
"SELECT id, user_id, filename, original_name, source, created_at "
|
||||
"SELECT id, user_id, filename, original_name, source, scene_kind, created_at "
|
||||
"FROM _user_assets WHERE user_id = ? ORDER BY id DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
@@ -5,7 +5,7 @@ from .create_user_asset import create_user_asset
|
||||
from .user_assets_dir import user_assets_dir
|
||||
|
||||
|
||||
def save_user_asset(user_id, content, ext, original_name=None, source="upload"):
|
||||
def save_user_asset(user_id, content, ext, original_name=None, source="upload", scene_kind=None):
|
||||
"""Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" —
|
||||
factorisé pour être partagé par l'upload manuel
|
||||
(routes/assets/upload_user_asset.py) ET la génération IA
|
||||
@@ -16,5 +16,5 @@ def save_user_asset(user_id, content, ext, original_name=None, source="upload"):
|
||||
os.makedirs(asset_dir, exist_ok=True)
|
||||
with open(os.path.join(asset_dir, filename), "wb") as f:
|
||||
f.write(content)
|
||||
asset_id = create_user_asset(user_id, filename, original_name=original_name, source=source)
|
||||
asset_id = create_user_asset(user_id, filename, original_name=original_name, source=source, scene_kind=scene_kind)
|
||||
return asset_id, filename
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from .connection import connect
|
||||
from .ensure_user_assets_schema import ensure_user_assets_schema
|
||||
from .get_user_asset import get_user_asset
|
||||
from .user_asset_kind import user_asset_kind
|
||||
|
||||
|
||||
def update_user_asset_scene_kind(asset_id, user_id, scene_kind):
|
||||
"""Reclasse une image déjà importée entre "Fonds" et "Décors/objets"
|
||||
(voir create_user_asset.py) — SANS ré-upload (demande implicite,
|
||||
trouvée en diagnostiquant un "décalage" : une image importée AVANT
|
||||
la séparation fond/décor, ou par le mauvais bouton, restait bloquée
|
||||
dans la mauvaise section, impossible à poser comme fond sans la
|
||||
ré-importer en double). Ne touche jamais un objet DÉJÀ posé sur une
|
||||
scène (kind="decor" dans _scene_objects) — l'auteur doit re-poser
|
||||
l'image depuis la bonne section après reclassement, même geste
|
||||
qu'un ajout normal."""
|
||||
if scene_kind not in ("decor", "fond"):
|
||||
return False
|
||||
ensure_user_assets_schema()
|
||||
asset = get_user_asset(asset_id)
|
||||
if not asset or asset["user_id"] != user_id:
|
||||
return False
|
||||
if user_asset_kind(asset["filename"]) != "image":
|
||||
return False
|
||||
conn = connect()
|
||||
conn.execute("UPDATE _user_assets SET scene_kind = ? WHERE id = ?", (scene_kind, asset_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
# "image"/"audio"/"video"/"other" d'après l'EXTENSION du fichier (voir
|
||||
# _user_assets.filename, auth/save_user_asset.py) — pas de colonne dédiée
|
||||
# en base : l'extension suffit, jamais ambiguë ici (tout upload passe par
|
||||
# routes/assets/upload_user_asset.py, qui valide déjà le contenu pour un
|
||||
# son, voir MAX_AUDIO_SECONDS). Utilisé pour organiser "Mes assets" en
|
||||
# sous-sections (demande explicite : "voir, utiliser ou supprimer des
|
||||
# son, image et vidéo", voir templates/scene_edit.html) — le sélecteur de
|
||||
# fichier des actions "son"/"vidéo" (user_assets_options_json,
|
||||
# routes/scenes/scene_edit_view.py), lui, liste TOUT sans distinction.
|
||||
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"}
|
||||
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"}
|
||||
_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"}
|
||||
|
||||
|
||||
def user_asset_kind(filename):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
return "image"
|
||||
if ext in _AUDIO_EXTENSIONS:
|
||||
return "audio"
|
||||
if ext in _VIDEO_EXTENSIONS:
|
||||
return "video"
|
||||
return "other"
|
||||
@@ -0,0 +1,25 @@
|
||||
import io
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
# Demande explicite : un son importé dans "Mes assets" ne doit jamais
|
||||
# dépasser 3 minutes (durée d'un effet sonore/d'une courte musique
|
||||
# d'ambiance — pas un morceau entier, voir l'action "son",
|
||||
# screens/rendering/collision_rules.py).
|
||||
MAX_AUDIO_SECONDS = 180
|
||||
|
||||
|
||||
def validate_audio_duration(content, max_seconds=MAX_AUDIO_SECONDS):
|
||||
"""Lit la durée d'un fichier audio (mp3/wav/ogg/m4a/... — mutagen
|
||||
détecte le format lui-même, pas besoin de le lui préciser) et renvoie
|
||||
un message d'erreur (français, prêt à afficher) si elle dépasse
|
||||
`max_seconds`, ou si le format n'a pas pu être reconnu du tout (repli
|
||||
prudent : un fichier illisible n'est jamais accepté silencieusement).
|
||||
Renvoie None si la durée est correcte."""
|
||||
audio = MutagenFile(io.BytesIO(content))
|
||||
if audio is None or audio.info is None or not getattr(audio.info, "length", None):
|
||||
return "Format audio non reconnu."
|
||||
duration = audio.info.length
|
||||
if duration > max_seconds:
|
||||
return f"Ce son dépasse {max_seconds // 60} minutes (durée : {int(duration)} secondes)."
|
||||
return None
|
||||
@@ -0,0 +1,32 @@
|
||||
import io
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
# Demande explicite : une vidéo importée dans "Mes assets" ne doit jamais
|
||||
# dépasser 5 minutes (voir l'action "vidéo", screens/rendering/
|
||||
# collision_rules.py — pensée pour une courte cinématique/cutscene, pas un
|
||||
# film entier), même esprit que MAX_AUDIO_SECONDS pour un son.
|
||||
MAX_VIDEO_SECONDS = 300
|
||||
|
||||
|
||||
def validate_video_duration(content, filename="video.mp4", max_seconds=MAX_VIDEO_SECONDS):
|
||||
"""Lit la durée d'un fichier vidéo MP4 (seul format accepté à l'upload,
|
||||
voir routes/assets/upload_user_asset.py — mutagen ne sait pas lire
|
||||
fiablement la durée de webm/mov/avi, contrairement au conteneur MP4/
|
||||
MOV ISO base media, qu'il décode via mutagen.mp4) et renvoie un
|
||||
message d'erreur (français, prêt à afficher) si elle dépasse
|
||||
`max_seconds`, ou si le format n'a pas pu être reconnu du tout (repli
|
||||
prudent : un fichier illisible n'est jamais accepté silencieusement).
|
||||
Renvoie None si la durée est correcte.
|
||||
|
||||
filename (juste l'extension importe) : contrairement à un son (voir
|
||||
validate_audio_duration.py), la détection MP4 de mutagen SANS indice
|
||||
de nom de fichier échoue silencieusement sur certains fichiers
|
||||
(moov/ftyp minimaux) — passer ".mp4" explicitement la rend fiable."""
|
||||
video = MutagenFile(io.BytesIO(content), filename=filename)
|
||||
if video is None or video.info is None or not getattr(video.info, "length", None):
|
||||
return "Format vidéo non reconnu."
|
||||
duration = video.info.length
|
||||
if duration > max_seconds:
|
||||
return f"Cette vidéo dépasse {max_seconds // 60} minutes (durée : {int(duration)} secondes)."
|
||||
return None
|
||||
+11
-1
@@ -19,7 +19,17 @@ def _sanitize_dialogue_line(raw_line):
|
||||
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()}
|
||||
line = {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}
|
||||
# Réplique audio (demande explicite : "ajouter une réplique audio")
|
||||
# — une voix off jouée en même temps que la bulle s'affiche, voir
|
||||
# "Mes assets" (un fichier déjà importé, jamais un chemin arbitraire
|
||||
# posté à la main : la sélection se fait via un <select>, pas un
|
||||
# champ texte libre, voir static/js/triggers/trigger-editor.js).
|
||||
# Optionnel : une réplique reste valide sans voix off.
|
||||
audio_url = raw_line.get("audio_url")
|
||||
if isinstance(audio_url, str) and audio_url.strip():
|
||||
line["audio_url"] = audio_url.strip()
|
||||
return line
|
||||
|
||||
|
||||
def _sanitize_question_line(raw_line):
|
||||
|
||||
@@ -9,3 +9,8 @@ qrcode==8.2
|
||||
anthropic==1.4.0
|
||||
requests==2.34.2
|
||||
python-dotenv==1.2.3
|
||||
# "Mes assets" — validation de la durée d'un son uploadé (voir
|
||||
# routes/assets/upload_user_asset.py) : lit la durée d'un fichier audio
|
||||
# quel que soit son format (mp3/wav/ogg/m4a/...) sans dépendance
|
||||
# compilée (pur Python).
|
||||
mutagen==1.48.1
|
||||
|
||||
@@ -1 +1 @@
|
||||
from . import upload_user_asset, serve_user_asset
|
||||
from . import upload_user_asset, serve_user_asset, delete_user_asset, set_user_asset_scene_kind
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from flask import g, jsonify
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/assets/<int:asset_id>", methods=["DELETE"])
|
||||
def delete_user_asset_route(asset_id):
|
||||
"""Supprime un fichier de "Mes assets" (demande explicite : "voir,
|
||||
utiliser ou supprimer des son, image et vidéo") — auth.delete_user_asset
|
||||
vérifie déjà que `asset_id` appartient bien au compte connecté avant
|
||||
de toucher au disque/à la base, jamais fourni tel quel."""
|
||||
ok = auth.delete_user_asset(asset_id, g.current_user["id"])
|
||||
if not ok:
|
||||
return jsonify({"error": "Fichier introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import g, request, jsonify
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/assets/<int:asset_id>/scene-kind", methods=["POST"])
|
||||
def set_user_asset_scene_kind_route(asset_id):
|
||||
"""Reclasse une image "Mes assets" entre "Fonds" et "Décors/objets"
|
||||
sans avoir à la ré-importer (voir auth/update_user_asset_scene_kind.py
|
||||
— diagnostiqué depuis un "décalage" signalé par le créateur : une
|
||||
image importée avant la séparation fond/décor, ou via le mauvais
|
||||
bouton, restait bloquée dans la mauvaise section)."""
|
||||
scene_kind = request.get_json(force=True).get("scene_kind")
|
||||
if scene_kind not in ("decor", "fond"):
|
||||
return jsonify({"error": "Type invalide"}), 400
|
||||
ok = auth.update_user_asset_scene_kind(asset_id, g.current_user["id"], scene_kind)
|
||||
if not ok:
|
||||
return jsonify({"error": "Fichier introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
@@ -19,8 +19,39 @@ def upload_user_asset():
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "Aucun fichier reçu"}), 400
|
||||
ext = "".join(c for c in os.path.splitext(f.filename)[1].lower() if c.isalnum() or c == ".")[:10]
|
||||
content = f.read()
|
||||
file_kind = auth.user_asset_kind(f.filename)
|
||||
# Un son ne doit jamais dépasser 3 minutes (demande explicite, voir
|
||||
# auth/validate_audio_duration.py) — vérifié sur le CONTENU réel du
|
||||
# fichier (mutagen lit ses métadonnées), jamais sur une durée
|
||||
# annoncée par le client, qui ne serait pas fiable.
|
||||
if file_kind == "audio":
|
||||
error = auth.validate_audio_duration(content)
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
# Une vidéo ne doit jamais dépasser 5 minutes, et SEUL le format MP4
|
||||
# est accepté (demande explicite) — mutagen (qui lit déjà la durée
|
||||
# d'un son ci-dessus) ne sait fiablement lire la durée que du
|
||||
# conteneur MP4/MOV, jamais de webm/avi ; sans cette restriction,
|
||||
# d'autres extensions passeraient la limite de durée sans jamais être
|
||||
# vérifiées.
|
||||
if file_kind == "video":
|
||||
if ext != ".mp4":
|
||||
return jsonify({"error": "Seul le format MP4 est accepté pour une vidéo."}), 400
|
||||
error = auth.validate_video_duration(content, filename=f.filename)
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
# "decor"/"fond" (demande explicite : "un bouton d'import séparé pour
|
||||
# les images de fond et les objets") — décide comment un clic sur la
|
||||
# tuile pose l'objet (voir screens/scenes/add_scene_object.py). Sans
|
||||
# objet ("son"/"vidéo"), le champ n'a pas de sens : jamais transmis.
|
||||
scene_kind = None
|
||||
if file_kind == "image":
|
||||
scene_kind = request.form.get("scene_kind")
|
||||
if scene_kind not in ("decor", "fond"):
|
||||
scene_kind = "decor"
|
||||
user_id = g.current_user["id"]
|
||||
asset_id, filename = auth.save_user_asset(user_id, f.read(), ext, original_name=f.filename, source="upload")
|
||||
asset_id, filename = auth.save_user_asset(user_id, content, ext, original_name=f.filename, source="upload", scene_kind=scene_kind)
|
||||
return jsonify({
|
||||
"id": asset_id,
|
||||
"url": url_for("serve_user_asset", user_id=user_id, filename=filename),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from flask import render_template, request, abort, g
|
||||
from flask import render_template, request, abort, g, url_for
|
||||
|
||||
import auth
|
||||
import db
|
||||
@@ -85,7 +85,33 @@ def render_scene_edit(slug, screen_id, game):
|
||||
# "Mes assets" (voir auth/user_assets_dir.py) : par COMPTE, jamais
|
||||
# filtré par rôle contrairement à sprite_library/background_library
|
||||
# ci-dessus (rien à voir avec le catalogue Forge réservé à l'admin).
|
||||
user_assets = auth.list_user_assets(g.current_user["id"])
|
||||
all_user_assets = auth.list_user_assets(g.current_user["id"])
|
||||
# Sélecteur "asset_url" des actions "son"/"vidéo" (voir static/js/
|
||||
# scenes/collision-rules-editor.js) — TOUS les assets (pas seulement
|
||||
# les images) : url_for calculée ici (pas possible côté JS), le reste
|
||||
# (id, nom) sert juste à l'affichage.
|
||||
for a in all_user_assets:
|
||||
a["url"] = url_for("serve_user_asset", user_id=a["user_id"], filename=a["filename"])
|
||||
a["kind"] = auth.user_asset_kind(a["filename"])
|
||||
user_assets_options = [
|
||||
{"id": a["id"], "url": a["url"], "name": a["original_name"] or a["filename"], "kind": a["kind"]}
|
||||
for a in all_user_assets
|
||||
]
|
||||
# "Mes assets" : sous-sections Fonds/Décors/Sons/Vidéos (demande
|
||||
# explicite : "voir, utiliser ou supprimer des son, image et vidéo",
|
||||
# PUIS "un bouton d'import séparé pour les images de fond et les
|
||||
# objets" — sans ça, un clic posait TOUJOURS l'image en kind="decor",
|
||||
# jamais en "fond", voir screens/scenes/add_scene_object.py). Une
|
||||
# image importée AVANT ce réglage (scene_kind NULL) retombe sur
|
||||
# "decor", comportement inchangé. Les sons/vidéos ne posent rien sur
|
||||
# la scène (ils s'utilisent depuis le sélecteur de fichier des
|
||||
# actions "son"/"vidéo", voir user_assets_options ci-dessus) — les
|
||||
# quatre listent néanmoins un bouton "Supprimer" (voir
|
||||
# deleteUserAsset(), static/js/scenes/scene-editor.js).
|
||||
user_assets_fond = [a for a in all_user_assets if a["kind"] == "image" and a["scene_kind"] == "fond"]
|
||||
user_assets_decor = [a for a in all_user_assets if a["kind"] == "image" and a["scene_kind"] != "fond"]
|
||||
user_assets_audio = [a for a in all_user_assets if a["kind"] == "audio"]
|
||||
user_assets_video = [a for a in all_user_assets if a["kind"] == "video"]
|
||||
|
||||
return render_template(
|
||||
"scene_edit.html",
|
||||
@@ -101,7 +127,8 @@ def render_scene_edit(slug, screen_id, game):
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
background_library=background_library,
|
||||
user_assets=user_assets,
|
||||
user_assets_fond=user_assets_fond, user_assets_decor=user_assets_decor,
|
||||
user_assets_audio=user_assets_audio, user_assets_video=user_assets_video,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||
@@ -134,4 +161,12 @@ def render_scene_edit(slug, screen_id, game):
|
||||
# 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)),
|
||||
# Nouveaux déclencheurs/actions (voir screens/rendering/
|
||||
# collision_rules.py, screens/rendering/screen_triggers.py) :
|
||||
# vocabulaire des nouvelles actions feuilles + assets disponibles
|
||||
# pour "son"/"vidéo", posés en JS comme le reste ci-dessus.
|
||||
surbrillance_labels_json=json.dumps(screens.SURBRILLANCE_LABELS),
|
||||
video_mode_labels_json=json.dumps(screens.VIDEO_MODE_LABELS),
|
||||
user_assets_options_json=json.dumps(user_assets_options),
|
||||
screen_triggers=screens.resolve_screen_triggers(screen),
|
||||
)
|
||||
|
||||
@@ -29,11 +29,19 @@ def scene_object_add(slug, screen_id):
|
||||
forge_character = request.form.get("forge_character")
|
||||
background_slug = request.form.get("background_slug")
|
||||
asset_id = request.form.get("asset_id", type=int)
|
||||
# Vrai glisser-déposer depuis la galerie (demande explicite : "n'importe
|
||||
# où sur la scène", voir static/js/scenes/scene-editor.js::onCanvasDrop)
|
||||
# — position EXACTE du dépôt, en pixels "monde" déjà calculés côté
|
||||
# client (pas de zoom sur #canvas, voir onCanvasDrop). Absents pour un
|
||||
# simple clic sur la galerie (comportement par défaut inchangé).
|
||||
drop_x = request.form.get("x", type=float)
|
||||
drop_y = request.form.get("y", type=float)
|
||||
if forge_character:
|
||||
forbid_admin_only_character(forge_character)
|
||||
if background_slug:
|
||||
forbid_admin_only_background(background_slug)
|
||||
image_url = None
|
||||
image_width = image_height = None
|
||||
if asset_id:
|
||||
# "Mes assets" (voir auth/user_assets_dir.py) : isolation stricte
|
||||
# par propriétaire, même esprit que les projets (core/auth_guard.py)
|
||||
@@ -42,10 +50,49 @@ def scene_object_add(slug, screen_id):
|
||||
if not asset or asset["user_id"] != g.current_user["id"]:
|
||||
abort(403)
|
||||
image_url = url_for("serve_user_asset", user_id=asset["user_id"], filename=asset["filename"])
|
||||
if kind == "fond":
|
||||
# Sans ça, un "fond" importé (contrairement à un fond du
|
||||
# catalogue Forge, voir background_library.py) retombait
|
||||
# TOUJOURS sur 128×128 (les valeurs par défaut du schéma) —
|
||||
# trop petit pour agrandir le "monde" au-delà du champ de la
|
||||
# caméra, rendant impossible d'y poser un objet en dehors de
|
||||
# ce champ (voir screens.resolve_scene_world_size). Un "decor"
|
||||
# n'en a pas besoin (demande explicite : toujours 200×200 à la
|
||||
# pose, voir add_scene_object.py::_DECOR_DEFAULT_SIZE).
|
||||
import os
|
||||
path = os.path.join(auth.user_assets_dir(asset["user_id"]), asset["filename"])
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
dims = auth.image_dimensions(fh.read())
|
||||
except OSError:
|
||||
dims = None
|
||||
if dims:
|
||||
image_width, image_height = dims
|
||||
# Un "fond" reste TOUJOURS ancré au coin haut-gauche du monde (0,0),
|
||||
# jamais à la position du dépôt (bug corrigé : "décalage" — le vrai
|
||||
# glisser-déposer envoie une position EXACTE même pour un fond, ce qui
|
||||
# le décalait du coin de la scène et laissait un bandeau de canevas vide
|
||||
# visible sur le dessus/le côté). Un "decor"/"personnage" garde lui sa
|
||||
# position de dépôt normale.
|
||||
new_id = screens.add_scene_object(
|
||||
slug, screen_id, kind=kind, forge_character=forge_character,
|
||||
background_slug=background_slug, image_url=image_url,
|
||||
image_width=image_width, image_height=image_height,
|
||||
x=(None if kind == "fond" else drop_x), y=(None if kind == "fond" else drop_y),
|
||||
)
|
||||
if drop_x is not None and drop_y is not None and kind != "fond":
|
||||
# Ramène dans le cadre de la caméra si le dépôt était trop proche
|
||||
# d'un bord (l'objet, une fois sa taille réelle connue, pourrait
|
||||
# légèrement déborder) — même esprit que ai/tools.py::
|
||||
# _clamp_to_camera pour l'IA, ici pour un dépôt manuel.
|
||||
obj_for_clamp = screens.get_scene_object(slug, new_id)
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
max_x = max(0, screen["scene_width"] - obj_for_clamp["width"])
|
||||
max_y = max(0, screen["scene_height"] - obj_for_clamp["height"])
|
||||
clamped_x = min(max(drop_x, 0), max_x)
|
||||
clamped_y = min(max(drop_y, 0), max_y)
|
||||
if (clamped_x, clamped_y) != (obj_for_clamp["x"], obj_for_clamp["y"]):
|
||||
screens.update_scene_object_geometry(slug, new_id, clamped_x, clamped_y, obj_for_clamp["width"], obj_for_clamp["height"])
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
obj = screens.get_scene_object(slug, new_id)
|
||||
return jsonify({
|
||||
|
||||
@@ -16,4 +16,14 @@ def scene_object_geometry(slug, object_id):
|
||||
x=float(data["x"]), y=float(data["y"]),
|
||||
width=float(data["width"]), height=float(data["height"]),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
# Un "fond" agrandi manuellement au-delà du champ de la caméra doit
|
||||
# élargir le "monde" tout de suite (voir screens.resolve_scene_world_size)
|
||||
# — sans ça, rien ne rafraîchissait window.SCENE_WIDTH/SCENE_HEIGHT côté
|
||||
# JS après un redimensionnement manuel (contrairement au tour de Ruby,
|
||||
# voir iaChatRefreshAfterTurn() dans static/js/ia/ia-chat.js), rendant
|
||||
# injoignable à la souris tout ce qui dépassait la scène nominale.
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
screen = screens.get_screen(slug, obj["scene_id"])
|
||||
objects = screens.list_scene_objects(slug, obj["scene_id"])
|
||||
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
||||
return jsonify({"ok": True, "world_width": world_width, "world_height": world_height})
|
||||
|
||||
@@ -1 +1 @@
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_background_music, screen_set_scene_size
|
||||
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_background_music, screen_set_scene_size, screen_triggers
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers", methods=["GET"])
|
||||
def screen_triggers_get(slug, screen_id):
|
||||
"""Déclencheurs D'ÉCRAN de CET écran (voir "Déclencheurs de l'écran",
|
||||
screens/rendering/screen_triggers.py) — SANS objet requis, pour de la
|
||||
narration/cinématique dès l'affichage."""
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return jsonify({"error": "écran introuvable"}), 404
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
# Rétro-remplissage des feuilles sans id (voir screens/rendering/
|
||||
# trigger_graph.py::list_all_triggers, même logique côté objet) —
|
||||
# persisté dès ce premier affichage.
|
||||
if screens.backfill_missing_leaf_ids(rules):
|
||||
screens.set_screen_triggers(slug, screen_id, rules)
|
||||
return jsonify(rules)
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers", methods=["POST"])
|
||||
def screen_triggers_add(slug, screen_id):
|
||||
"""Ajoute une règle "déclencheur d'écran -> action" — body {rule}, même
|
||||
contrat "liste ENTIÈRE" que routes/triggers/triggers.py::triggers_add :
|
||||
on recharge les règles existantes de l'écran, on y ajoute la nouvelle,
|
||||
on sauvegarde la liste complète."""
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return jsonify({"error": "écran introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
new_rule = body.get("rule")
|
||||
if not isinstance(new_rule, dict):
|
||||
return jsonify({"error": "rule invalide"}), 400
|
||||
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
rules.append(new_rule)
|
||||
sanitized = screens.sanitize_screen_triggers(rules)
|
||||
screens.set_screen_triggers(slug, screen_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers", methods=["DELETE"])
|
||||
def screen_triggers_clear(slug, screen_id):
|
||||
"""Retire TOUTES les règles d'écran de CET écran."""
|
||||
if screens.get_screen(slug, screen_id) is None:
|
||||
return jsonify({"error": "écran introuvable"}), 404
|
||||
screens.set_screen_triggers(slug, screen_id, [])
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers/<int:index>", methods=["DELETE"])
|
||||
def screen_trigger_delete_one(slug, screen_id, index):
|
||||
"""Retire UNE SEULE règle d'écran (son index dans la liste) —
|
||||
contrairement aux déclencheurs d'objet (un seul par objet en
|
||||
pratique), un écran peut porter plusieurs règles "affichage" côte à
|
||||
côte : la suppression doit donc cibler l'une d'elles, pas tout
|
||||
vider (voir screen_triggers_clear ci-dessus, conservée pour un vidage
|
||||
complet)."""
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return jsonify({"error": "écran introuvable"}), 404
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
if index < 0 or index >= len(rules):
|
||||
return jsonify({"error": "règle introuvable"}), 404
|
||||
del rules[index]
|
||||
screens.set_screen_triggers(slug, screen_id, rules)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers/action/<leaf_id>", methods=["DELETE"])
|
||||
def screen_trigger_remove_action(slug, screen_id, leaf_id):
|
||||
"""Retire UNE action précise d'un déclencheur d'écran — même contrat
|
||||
que routes/triggers/triggers.py::trigger_remove_action."""
|
||||
if not screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id):
|
||||
return jsonify({"error": "action introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers/action/<leaf_id>/move", methods=["POST"])
|
||||
def screen_trigger_move_action(slug, screen_id, leaf_id):
|
||||
"""Déplace une action précise d'un cran ("up"/"down") dans sa chaîne,
|
||||
pour un déclencheur d'écran — même contrat que
|
||||
routes/triggers/triggers.py::trigger_move_action."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
direction = body.get("direction")
|
||||
if direction not in ("up", "down"):
|
||||
return jsonify({"error": "direction invalide"}), 400
|
||||
if not screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction):
|
||||
return jsonify({"error": "action introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers/dialogue", methods=["POST"])
|
||||
def screen_trigger_dialogue_update(slug, screen_id):
|
||||
"""Édition EN LIGNE des répliques d'un dialogue porté par un
|
||||
déclencheur d'écran — même contrat que
|
||||
routes/triggers/triggers.py::trigger_dialogue_update."""
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
return jsonify({"error": "écran introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
dialogue_id = body.get("dialogue_id")
|
||||
if not isinstance(dialogue_id, str) or not dialogue_id.strip():
|
||||
return jsonify({"error": "dialogue_id invalide"}), 400
|
||||
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
if not screens.set_dialogue_lines_in_rules(rules, dialogue_id, body.get("lines")):
|
||||
return jsonify({"error": "dialogue introuvable"}), 404
|
||||
sanitized = screens.sanitize_screen_triggers(rules)
|
||||
screens.set_screen_triggers(slug, screen_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/triggers/append-action", methods=["POST"])
|
||||
def screen_trigger_append_action(slug, screen_id):
|
||||
""""+ Ajouter une action" en bout de chaîne, pour un déclencheur
|
||||
d'écran — même contrat que
|
||||
routes/triggers/triggers.py::trigger_append_action."""
|
||||
if screens.get_screen(slug, screen_id) is None:
|
||||
return jsonify({"error": "écran introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
after_id = body.get("after_id")
|
||||
if not isinstance(after_id, str) or not after_id.strip():
|
||||
return jsonify({"error": "after_id invalide"}), 400
|
||||
|
||||
if not screens.append_action_to_screen_trigger(slug, screen_id, after_id, body.get("action")):
|
||||
return jsonify({"error": "action précédente introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
@@ -89,13 +89,14 @@ def trigger_dialogue_update(slug, object_id):
|
||||
|
||||
@app.route("/game/<slug>/triggers/<int:object_id>/append-action", methods=["POST"])
|
||||
def trigger_append_action(slug, object_id):
|
||||
""""+ Ajouter une action" en bout de chaîne (voir screens/rendering/
|
||||
collision_rules.py::set_then_action_in_rules, static/js/triggers/
|
||||
trigger-editor.js) — body {after_id, action} : `after_id` est le
|
||||
dialogue.id (feuille "dialogue") ou id (feuille "variable") de la
|
||||
DERNIÈRE feuille d'une chaîne. 404 si `after_id` introuvable ou si
|
||||
cette feuille a déjà un "then" (jamais écrasé par cette route — un
|
||||
nouvel appel doit d'abord recharger la chaîne à jour)."""
|
||||
""""+ Ajouter une action" (voir screens/rendering/collision_rules.py::
|
||||
set_then_action_in_rules, static/js/triggers/trigger-editor.js) —
|
||||
body {after_id, action} : `after_id` est le dialogue.id (feuille
|
||||
"dialogue") ou id (toute autre feuille) de N'IMPORTE QUEL bloc de la
|
||||
chaîne, pas forcément le dernier — la nouvelle action s'INSÈRE juste
|
||||
après lui, une suite déjà présente passant derrière elle (demande
|
||||
explicite : "pouvoir ajouter n'importe quelle action à n'importe quel
|
||||
niveau du schéma"). 404 seulement si `after_id` est introuvable."""
|
||||
if screens.get_scene_object(slug, object_id) is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
@@ -106,5 +107,30 @@ def trigger_append_action(slug, object_id):
|
||||
# Chemin d'écriture PARTAGÉ avec l'agent IA (voir ai/tools.py) —
|
||||
# ni cette route ni l'agent n'écrivent _collision_rules directement.
|
||||
if not screens.append_action_to_trigger(slug, object_id, after_id, body.get("action")):
|
||||
return jsonify({"error": "action précédente introuvable ou déjà chaînée"}), 404
|
||||
return jsonify({"error": "action précédente introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers/<int:object_id>/action/<leaf_id>", methods=["DELETE"])
|
||||
def trigger_remove_action(slug, object_id, leaf_id):
|
||||
"""Retire UNE action précise (demande explicite : "chaque bloc
|
||||
étiquette + action doit être supprimable") — voir screens/rendering/
|
||||
collision_rules.py::remove_action_from_rules, même chemin d'écriture
|
||||
partagé avec l'agent IA."""
|
||||
if not screens.remove_action_from_trigger(slug, object_id, leaf_id):
|
||||
return jsonify({"error": "action introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/triggers/<int:object_id>/action/<leaf_id>/move", methods=["POST"])
|
||||
def trigger_move_action(slug, object_id, leaf_id):
|
||||
"""Déplace une action précise d'un cran ("up"/"down", body
|
||||
{direction}) dans sa chaîne — demande explicite : "chaque bloc
|
||||
étiquette + action doit être déplaçable"."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
direction = body.get("direction")
|
||||
if direction not in ("up", "down"):
|
||||
return jsonify({"error": "direction invalide"}), 400
|
||||
if not screens.move_action_in_trigger(slug, object_id, leaf_id, direction):
|
||||
return jsonify({"error": "action introuvable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@@ -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,
|
||||
# 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
|
||||
|
||||
|
||||
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"),
|
||||
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 (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()
|
||||
@@ -19,6 +19,7 @@ global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline
|
||||
const {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules,
|
||||
} = require('../collision-rules-controller.js');
|
||||
|
||||
// La bulle "Appuie sur X" est ajoutée comme SŒUR de l'objet dans son
|
||||
@@ -179,6 +180,137 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" bascule .forgeHighlight sur l\'objet CIBLÉ (object_id), pas forcément l\'objet du déclencheur', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.includes('"9"') ? targetEl : undefined) };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'surbrillance', object_id: 9, valeur: 'on' }, fakeEl(), { id: 1, name: 'Interrupteur' });
|
||||
assert.equal(targetEl.classList.contains('forgeHighlight'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "visibilite" masque l\'objet CIBLÉ', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'visibilite', object_id: 4, valeur: 'masque' }, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(targetEl.style.display, 'none');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" sans objet trouvé (référence orpheline) ne plante pas et exécute quand même "then"', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => undefined };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'surbrillance', object_id: 999, valeur: 'on',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "son" joue l\'URL fournie via Audio, instantané (then exécuté tout de suite)', () => {
|
||||
const previousAudio = global.Audio;
|
||||
const played = [];
|
||||
global.Audio = function (url) {
|
||||
played.push(url);
|
||||
return { play: () => Promise.resolve() };
|
||||
};
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'son', asset_url: '/assets/1/bip.mp3',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.deepEqual(played, ['/assets/1/bip.mp3']);
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant d\'exécuter "then"', () => {
|
||||
const previousShow = global.forgeShowVideoOverlay;
|
||||
let capturedDone;
|
||||
global.forgeShowVideoOverlay = (url, mode, onDone) => { capturedDone = onDone; };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'video', asset_url: '/assets/1/intro.mp4', mode: 'plein_ecran',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, la vidéo "joue"
|
||||
capturedDone();
|
||||
assert.equal(gameData.variables.score.value, '1'); // maintenant, la vidéo est "finie"
|
||||
} finally { global.forgeShowVideoOverlay = previousShow; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "attendre" suspend "then" jusqu\'à la fin du délai (data_value en secondes)', async () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'attendre', data_value: '0',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, setTimeout pas encore résolu
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 10); });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "indication" affiche une bulle temporisée près de l\'objet CIBLÉ', () => {
|
||||
const parentEl = fakeParentEl();
|
||||
const targetEl = fakeEl(parentEl);
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 4: targetEl });
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'indication', object_id: 4, texte: 'Regarde ici !' }, fakeEl(), { id: 1, name: 'x' });
|
||||
const bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="4"]');
|
||||
assert.equal(bubble.textContent, 'Regarde ici !');
|
||||
assert.equal(bubble.style.display, 'block');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueur ni tick', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeBindClicSurvolRules({
|
||||
elements: [{
|
||||
id: 5, collision_rules: [{
|
||||
trigger: 'clic',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
targetEl._handlers.click();
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur click', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeBindClicSurvolRules({ elements: [{ id: 6, collision_rules: [{ trigger: 'survol', action: { type: 'dialogue' } }] }] });
|
||||
assert.equal(typeof targetEl._handlers.mouseenter, 'function');
|
||||
assert.equal(targetEl._handlers.click, undefined);
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais lié en écouteur DOM (reste géré par le tick)', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeBindClicSurvolRules({ elements: [{ id: 7, collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] });
|
||||
assert.equal(targetEl._handlers, undefined);
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
@@ -261,6 +393,14 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col
|
||||
});
|
||||
|
||||
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
|
||||
// "clic"/"survol" sont liés inconditionnellement (forgeBindClicSurvolRules,
|
||||
// voir plus bas) — jamais de joueur requis pour EUX, donc document.querySelector
|
||||
// est bien appelé même dans ces deux cas où le tick de collision, lui, ne
|
||||
// démarre pas ; un document minimal (aucun élément trouvé) suffit ici.
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => undefined };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -103,6 +103,54 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — une réplique avec audio_url joue la voix off dès son affichage', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
const played = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
played.push(url);
|
||||
return { play: () => Promise.resolve(), pause: () => {} };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [{ speaker: 'Marc', text: 'Bonjour !', audio_url: '/assets/1/bonjour.mp3' }]);
|
||||
assert.deepEqual(played, ['/assets/1/bonjour.mp3']);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — une réplique SANS audio_url ne tente jamais de jouer un son', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
let called = false;
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function () { called = true; return { play: () => Promise.resolve(), pause: () => {} }; };
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [{ speaker: 'Marc', text: 'Bonjour !' }]);
|
||||
assert.equal(called, false);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — la voix off de la réplique précédente s\'arrête sur la suivante', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
const paused = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
return { url: url, play: () => Promise.resolve(), pause: function () { paused.push(this.url); } };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [
|
||||
{ speaker: 'Marc', text: 'Bonjour !', audio_url: '/assets/1/a.mp3' },
|
||||
{ speaker: 'Marc', text: 'Ça va ?', audio_url: '/assets/1/b.mp3' },
|
||||
]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.deepEqual(paused, ['/assets/1/a.mp3']);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir
|
||||
// static/js/play/screen-triggers-controller.js, screens/rendering/
|
||||
// screen_triggers.py) — SANS objet requis, narration/cinématique.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
global.forgeRunCollisionRuleAction = require('../collision-rules-controller.js').forgeRunCollisionRuleAction;
|
||||
|
||||
const { forgeRunScreenTriggers } = require('../screen-triggers-controller.js');
|
||||
|
||||
test('forgeRunScreenTriggers — ignore un écran sans screen_triggers', () => {
|
||||
assert.doesNotThrow(() => forgeRunScreenTriggers({ id: 1 }));
|
||||
assert.doesNotThrow(() => forgeRunScreenTriggers(null));
|
||||
});
|
||||
|
||||
test('forgeRunScreenTriggers — exécute une action "variable" pour un déclencheur "affichage"', () => {
|
||||
global.gameData = { variables: { intro_vue: { type: 'booleen', value: 'faux' } } };
|
||||
forgeRunScreenTriggers({
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'affichage',
|
||||
action: { type: 'variable', target_variable: 'intro_vue', data_operation: 'definir_bool_vrai' },
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.intro_vue.value, '1');
|
||||
});
|
||||
|
||||
test('forgeRunScreenTriggers — ignore tout déclencheur autre que "affichage" (jamais posé en pratique, garde-fou)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunScreenTriggers({
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'collision',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.score.value, '0');
|
||||
});
|
||||
@@ -112,6 +112,42 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---------- Action "indication" — bulle de texte libre, temporisée ----------
|
||||
// Distincte de la bulle "Appuie sur X" ci-dessus (sa propre classe CSS,
|
||||
// sa propre minuterie) : ne dépend jamais du cycle "actif tant que la
|
||||
// collision dure" d'un déclencheur "interagir", peut être posée sur
|
||||
// N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas
|
||||
// nécessairement l'objet qui porte le déclencheur).
|
||||
var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
var forgeIndicationTimeouts = {};
|
||||
|
||||
function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]');
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
var targetEl = document.querySelector('[data-object-id="' + objectId + '"]');
|
||||
var parentEl = targetEl && targetEl.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
var objRect = forgeElementBoxRect(targetEl);
|
||||
var bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = texte;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px';
|
||||
bubble.style.top = objRect.top + 'px';
|
||||
if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]);
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(function () {
|
||||
bubble.style.display = 'none';
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
@@ -156,6 +192,40 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
||||
var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]');
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'son') {
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(function () {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'video') {
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
||||
// qu'un dialogue ci-dessus.
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, function () {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'attendre') {
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même
|
||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
||||
// narration à l'affichage de l'écran.
|
||||
var seconds = parseFloat(action.data_value);
|
||||
var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(function () {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +244,11 @@ function forgeCollisionRuleTick(screenData) {
|
||||
|
||||
var anyInteractActive = false;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
// "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules,
|
||||
// écouteurs DOM directs) — jamais évalués ici, sans quoi ils
|
||||
// s'exécuteraient EN PLUS, à la proximité du joueur, en plus de
|
||||
// leur propre déclencheur.
|
||||
if (rule.trigger !== 'collision') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
@@ -217,6 +292,36 @@ function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
window.addEventListener('keydown', activeCollisionRuleInteractListener);
|
||||
}
|
||||
|
||||
// ---------- Déclencheurs "clic"/"survol" — écouteurs DOM directs ----------
|
||||
// Contrairement à "collision" (tick + proximité du joueur), ces deux
|
||||
// déclencheurs n'ont besoin NI d'un joueur NI d'un tick : un écouteur
|
||||
// posé une fois par objet suffit (voir screens/rendering/
|
||||
// collision_rules.py). Re-attachés à chaque affichage d'écran, comme le
|
||||
// reste de ce fichier.
|
||||
var activeClicSurvolListeners = [];
|
||||
|
||||
function forgeUnbindClicSurvolRules() {
|
||||
activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners = [];
|
||||
}
|
||||
|
||||
function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule) {
|
||||
if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return;
|
||||
var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
targetEl.addEventListener(eventName, handler);
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeStopCollisionRuleControllers() {
|
||||
if (activeCollisionRuleInterval) {
|
||||
clearInterval(activeCollisionRuleInterval);
|
||||
@@ -226,16 +331,20 @@ function forgeStopCollisionRuleControllers() {
|
||||
window.removeEventListener('keydown', activeCollisionRuleInteractListener);
|
||||
activeCollisionRuleInteractListener = null;
|
||||
}
|
||||
forgeUnbindClicSurvolRules();
|
||||
forgeCollisionRuleActiveState = {};
|
||||
}
|
||||
|
||||
// Même patron par écran que forgeStartPersonnageControllers — appelé
|
||||
// juste après lui (voir static/js/play/screens.js), n'a d'effet que si
|
||||
// l'écran a un joueur ET au moins un objet avec des règles.
|
||||
// juste après lui (voir static/js/play/screens.js). "clic"/"survol" sont
|
||||
// liés inconditionnellement (aucun joueur requis) ; le tick de collision
|
||||
// + l'écouteur "interagir" ne démarrent, eux, que si l'écran a un joueur
|
||||
// ET au moins un objet avec des règles.
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; });
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
@@ -249,6 +358,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble,
|
||||
};
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
@@ -259,4 +369,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||
const dialogueBoxMod = require('./dialogue-box-controller.js');
|
||||
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
|
||||
const actionsMod = require('./actions.js');
|
||||
global.applyObjectProperty = global.applyObjectProperty || actionsMod.applyObjectProperty;
|
||||
const videoOverlayMod = require('./video-overlay.js');
|
||||
global.forgeShowVideoOverlay = global.forgeShowVideoOverlay || videoOverlayMod.forgeShowVideoOverlay;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,30 @@ function forgeDialogueBoxFooterEl(widget) {
|
||||
return widget.querySelector('[data-dialogue-role="footer"]');
|
||||
}
|
||||
|
||||
// Voix off d'une réplique (demande explicite : "ajouter une réplique
|
||||
// audio", voir db/dialogue_lines.py::"dialogue".audio_url) — jouée dès
|
||||
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
|
||||
// sa propre voix off) prend sa place, pour ne jamais superposer deux
|
||||
// voix off en même temps si le joueur avance vite dans la conversation.
|
||||
var forgeDialogueLineAudio = null;
|
||||
|
||||
function forgeStopDialogueLineAudio() {
|
||||
if (forgeDialogueLineAudio) {
|
||||
forgeDialogueLineAudio.pause();
|
||||
forgeDialogueLineAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
if (header) header.textContent = line.speaker;
|
||||
if (body) body.textContent = line.text;
|
||||
forgeStopDialogueLineAudio();
|
||||
if (line.audio_url) {
|
||||
forgeDialogueLineAudio = new Audio(line.audio_url);
|
||||
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
|
||||
}
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
@@ -257,6 +276,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeStopDialogueLineAudio();
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// ---------- Déclencheurs D'ÉCRAN — "à l'affichage" (SANS objet) ----------
|
||||
// Voir screens/rendering/screen_triggers.py, section "Déclencheurs de
|
||||
// l'écran" — narration/cinématique dès l'affichage, sans avoir besoin
|
||||
// d'un objet de scène. Réutilise TEL QUEL le même moteur d'exécution
|
||||
// d'action que collision-rules-controller.js (forgeRunCollisionRuleAction)
|
||||
// plutôt que d'en dupliquer un second : targetEl/obj passés vides, une
|
||||
// action qui a besoin d'un objet précis le porte elle-même (object_id,
|
||||
// voir surbrillance/visibilite/indication).
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
var rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
rules.forEach(function (rule) {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
});
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRunScreenTriggers };
|
||||
}
|
||||
@@ -57,6 +57,10 @@ function showScreen(id) {
|
||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir screen-triggers-controller.js,
|
||||
// screens/rendering/screen_triggers.py) — narration/cinématique sans
|
||||
// objet requis, exécutée à chaque affichage de CET écran.
|
||||
forgeRunScreenTriggers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ---------- Action "video" — lecture d'une vidéo de "Mes assets" ----------
|
||||
// Voir screens/rendering/collision_rules.py::"video" — bloque la suite de
|
||||
// la chaîne "then" jusqu'à la fin de la vidéo (ou un clic sur "Passer"),
|
||||
// même esprit qu'un dialogue (voir dialogue-box-controller.js). Un seul
|
||||
// overlay posé sur <body>, réutilisé pour chaque lecture (pas la peine
|
||||
// d'en recréer un par appel).
|
||||
|
||||
var forgeVideoOverlayEl = null;
|
||||
|
||||
function forgeVideoOverlayRoot() {
|
||||
if (forgeVideoOverlayEl) return forgeVideoOverlayEl;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'forgeVideoOverlay';
|
||||
el.innerHTML =
|
||||
'<video class="forgeVideoOverlayPlayer" playsinline></video>' +
|
||||
'<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
document.body.appendChild(el);
|
||||
forgeVideoOverlayEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// `onDone` : appelé une fois (fin naturelle OU "Passer") — jamais les
|
||||
// deux, `finished` protège contre un double appel (l'événement 'ended'
|
||||
// pouvant encore se déclencher juste après un clic sur "Passer").
|
||||
function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
if (!url) {
|
||||
if (onDone) onDone();
|
||||
return;
|
||||
}
|
||||
var root = forgeVideoOverlayRoot();
|
||||
root.className = 'forgeVideoOverlay ' + (mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran');
|
||||
root.style.display = 'flex';
|
||||
var player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
var skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
var finished = false;
|
||||
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
root.style.display = 'none';
|
||||
player.pause();
|
||||
player.removeAttribute('src');
|
||||
player.onended = null;
|
||||
skipBtn.onclick = null;
|
||||
if (onDone) onDone();
|
||||
}
|
||||
|
||||
player.src = url;
|
||||
player.onended = finish;
|
||||
skipBtn.onclick = finish;
|
||||
player.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : reste affichée, "Passer" fonctionne toujours.
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeShowVideoOverlay };
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Assistant "déclencheur -> action" (voir static/js/scenes/
|
||||
// collision-rules-editor.js, static/js/triggers/trigger-editor.js) — ce
|
||||
// fichier n'a pas de module.exports (script de navigateur, pas un module
|
||||
// CommonJS) : on l'exécute via vm.runInThisContext (comme une vraie
|
||||
// balise <script>, les déclarations top-level deviennent globales) plutôt
|
||||
// que require(), qui les aurait gardées privées au module.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function loadAsScript(relativePath) {
|
||||
const fullPath = path.join(__dirname, relativePath);
|
||||
vm.runInThisContext(fs.readFileSync(fullPath, 'utf8'), { filename: fullPath });
|
||||
}
|
||||
|
||||
function makeEl() {
|
||||
const el = {
|
||||
_html: '', style: {}, value: '',
|
||||
classList: { add() {}, remove() {}, toggle() {} },
|
||||
children: [],
|
||||
appendChild(c) { this.children.push(c); },
|
||||
querySelector() { return null; },
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() { return this._html; },
|
||||
set(v) { this._html = v; },
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function setupGlobals() {
|
||||
global.window = global.window || {};
|
||||
global.GAME_SLUG = 'pytest_wizard';
|
||||
global.CURRENT_SCREEN_ID = 7;
|
||||
global.SCENE_OBJECT_NAMES_JSON = [];
|
||||
global.GLOBAL_VARIABLE_NAMES = [];
|
||||
global.DATA_OPERATION_LABELS_MAP = { incrementer: 'Incrémenter' };
|
||||
global.CONDITION_OPERATOR_LABELS_MAP = { egal: 'égal à' };
|
||||
global.SURBRILLANCE_LABELS_MAP = { on: 'Activer' };
|
||||
global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' };
|
||||
global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' };
|
||||
global.USER_ASSETS_OPTIONS = [];
|
||||
|
||||
const elementsById = {};
|
||||
global.document = {
|
||||
createElement() { return makeEl(); },
|
||||
getElementById(id) { return elementsById[id] || (elementsById[id] = makeEl()); },
|
||||
querySelector() { return null; },
|
||||
body: makeEl(),
|
||||
};
|
||||
|
||||
const posted = [];
|
||||
global.fetch = function (url, opts) {
|
||||
if (opts && opts.method === 'POST') posted.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
};
|
||||
return posted;
|
||||
}
|
||||
|
||||
// setupGlobals() DOIT être appelé avant de charger les scripts —
|
||||
// trigger-editor.js exécute initTriggersTab() à son propre chargement
|
||||
// (voir son commentaire "Point d'entrée"), qui a besoin de `document`.
|
||||
// Les scripts eux-mêmes ne sont chargés qu'UNE FOIS (déclarations
|
||||
// top-level -> globales, voir loadAsScript) ; chaque test rappelle
|
||||
// setupGlobals() seulement pour repartir d'un DOM/fetch factices propres,
|
||||
// jamais pour recharger les scripts.
|
||||
setupGlobals();
|
||||
loadAsScript('../collision-rules-editor.js');
|
||||
loadAsScript('../../triggers/trigger-editor.js');
|
||||
|
||||
test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES DEUX, dans le bon ordre', () => {
|
||||
// Bug corrigé : "Rien de plus" renvoyait la DERNIÈRE feuille ajoutée
|
||||
// (_collisionWizard.chainLeaf, réassignée à chaque étape) plutôt que la
|
||||
// PREMIÈRE — une chaîne de 2 actions ou plus perdait silencieusement
|
||||
// tout sauf la toute dernière avant sauvegarde.
|
||||
const posted = setupGlobals();
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('attendre', false);
|
||||
document.getElementById('attendreWizSecondes').value = '2';
|
||||
global.collisionWizardAttendreNext();
|
||||
global.collisionWizardChainDone('dialogue');
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
assert.ok(saveCall, 'la règle aurait dû être postée');
|
||||
const action = saveCall.body.rule.action;
|
||||
assert.equal(action.type, 'attendre');
|
||||
assert.equal(action.data_value, '2');
|
||||
assert.equal(action.then.type, 'dialogue');
|
||||
});
|
||||
|
||||
test('assistant — une chaîne de 3 actions ("variable" -> "attendre" -> "dialogue") garde la tête et l\'ordre', () => {
|
||||
const posted = setupGlobals();
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('variable', false);
|
||||
document.getElementById('varWizVariable').value = 'score';
|
||||
document.getElementById('varWizOperation').value = 'incrementer';
|
||||
document.getElementById('varWizValue').value = '1';
|
||||
global.collisionWizardVariableNext();
|
||||
global.collisionWizardChainDone('attendre');
|
||||
document.getElementById('attendreWizSecondes').value = '1.5';
|
||||
global.collisionWizardAttendreNext();
|
||||
global.collisionWizardChainDone('dialogue');
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
const action = saveCall.body.rule.action;
|
||||
assert.equal(action.type, 'variable');
|
||||
assert.equal(action.then.type, 'attendre');
|
||||
assert.equal(action.then.data_value, '1.5');
|
||||
assert.equal(action.then.then.type, 'dialogue');
|
||||
});
|
||||
|
||||
test('assistant — une seule action ("Rien de plus" tout de suite) sauvegarde bien cette action seule', () => {
|
||||
const posted = setupGlobals();
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('dialogue', false);
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
const action = saveCall.body.rule.action;
|
||||
assert.equal(action.type, 'dialogue');
|
||||
assert.equal(action.then, undefined);
|
||||
});
|
||||
|
||||
test('assistant — l\'action "vidéo" ne propose QUE les fichiers vidéo (jamais les images/sons)', () => {
|
||||
// Bug corrigé : "toute une liste de png et mp3" pour un déclencheur
|
||||
// vidéo — renderCollisionWizardAssetStep listait TOUJOURS
|
||||
// USER_ASSETS_OPTIONS en entier, sans filtrer par kind.
|
||||
setupGlobals();
|
||||
global.USER_ASSETS_OPTIONS = [
|
||||
{ id: 1, url: '/assets/1/photo.png', name: 'photo.png', kind: 'image' },
|
||||
{ id: 2, url: '/assets/1/bip.mp3', name: 'bip.mp3', kind: 'audio' },
|
||||
{ id: 3, url: '/assets/1/clip.mp4', name: 'clip.mp4', kind: 'video' },
|
||||
];
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('video', false);
|
||||
|
||||
const html = body.innerHTML;
|
||||
assert.ok(html.includes('clip.mp4'), 'le fichier vidéo doit apparaître');
|
||||
assert.ok(!html.includes('photo.png'), 'une image ne doit jamais apparaître pour une action vidéo');
|
||||
assert.ok(!html.includes('bip.mp3'), 'un son ne doit jamais apparaître pour une action vidéo');
|
||||
});
|
||||
|
||||
test('assistant — l\'action "son" ne propose QUE les fichiers audio (jamais les images/vidéos)', () => {
|
||||
setupGlobals();
|
||||
global.USER_ASSETS_OPTIONS = [
|
||||
{ id: 1, url: '/assets/1/photo.png', name: 'photo.png', kind: 'image' },
|
||||
{ id: 2, url: '/assets/1/bip.mp3', name: 'bip.mp3', kind: 'audio' },
|
||||
{ id: 3, url: '/assets/1/clip.mp4', name: 'clip.mp4', kind: 'video' },
|
||||
];
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('son', false);
|
||||
|
||||
const html = body.innerHTML;
|
||||
assert.ok(html.includes('bip.mp3'), 'le fichier son doit apparaître');
|
||||
assert.ok(!html.includes('photo.png'), 'une image ne doit jamais apparaître pour une action son');
|
||||
assert.ok(!html.includes('clip.mp4'), 'une vidéo ne doit jamais apparaître pour une action son');
|
||||
});
|
||||
|
||||
test('triggerActionChainHtml — un bouton "+ Ajouter une action" apparaît après CHAQUE bloc, pas seulement le dernier', () => {
|
||||
// Demande explicite : "pouvoir ajouter n'importe quelle action à
|
||||
// n'importe quel niveau du schéma" — chaque bloc doit pouvoir insérer
|
||||
// juste après lui, pas seulement le tout dernier de la chaîne.
|
||||
setupGlobals();
|
||||
const chain = [
|
||||
{ type: 'variable', id: 'v_1', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
{ type: 'attendre', id: 'a_1', data_value: '2' },
|
||||
{ type: 'dialogue', dialogue: { id: 'd_1', lines: [] } },
|
||||
];
|
||||
const html = global.triggerActionChainHtml(chain, 'screen', null);
|
||||
const matches = html.match(/\+ Ajouter une action/g) || [];
|
||||
assert.equal(matches.length, 3);
|
||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('v_1')"));
|
||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('a_1')"));
|
||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('d_1')"));
|
||||
});
|
||||
@@ -11,10 +11,23 @@
|
||||
|
||||
var COLLISION_ACTION_LABELS = {
|
||||
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
|
||||
surbrillance: 'Mettre en surbrillance', visibilite: 'Rendre visible/invisible',
|
||||
son: 'Jouer un son', video: 'Jouer une vidéo', indication: 'Afficher une indication',
|
||||
attendre: 'Attendre quelques secondes',
|
||||
interagir: 'Interagir avec une touche', condition: 'Si une condition est vraie',
|
||||
};
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'interagir', 'condition'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision' };
|
||||
// Ordre d'affichage — "interagir"/"condition" en dernier (comportements
|
||||
// spéciaux), le reste (les FEUILLES, voir screens/rendering/
|
||||
// collision_rules.py::LEAF_ACTION_TYPES) dans le même ordre que
|
||||
// COLLISION_CHAIN_CHOICES ci-dessous, pour rester cohérent d'un écran à
|
||||
// l'autre de l'assistant.
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'surbrillance', 'visibilite', 'son', 'video', 'indication', 'attendre', 'interagir', 'condition'];
|
||||
// FEUILLES chaînables via "then" — utilisées à la fois pour l'étape
|
||||
// "Ensuite ?" (renderCollisionWizardChainStep) et les branches Vrai/Faux
|
||||
// d'une condition (renderCollisionWizardBranchStep) : MÊME liste des deux
|
||||
// côtés, voir screens/rendering/collision_rules.py::LEAF_ACTION_TYPES.
|
||||
var COLLISION_CHAIN_CHOICES = ['dialogue', 'variable', 'surbrillance', 'visibilite', 'son', 'video', 'indication', 'attendre'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', clic: 'Au clic', survol: 'Au survol', affichage: 'À l\'affichage de l\'écran' };
|
||||
// Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||
@@ -60,7 +73,17 @@ var _collisionWizard = null;
|
||||
// Appelée après avoir choisi l'objet dans la modale d'ajout de déclencheur
|
||||
// (voir triggerSelectObject, trigger-editor.js).
|
||||
function openTriggerWizard(objectId, bodyEl) {
|
||||
_collisionWizard = { objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
_collisionWizard = { target: 'object', objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
// Section "Déclencheurs de l'écran" (voir static/js/triggers/
|
||||
// trigger-editor.js::openScreenAddTriggerModal) — même assistant, mais
|
||||
// SANS objet propriétaire (voir screens/rendering/screen_triggers.py) :
|
||||
// un seul déclencheur possible ("affichage"), donc l'étape 1 n'a qu'un
|
||||
// choix, gardée pour rester cohérente avec le parcours objet.
|
||||
function openScreenTriggerWizard(screenId, bodyEl) {
|
||||
_collisionWizard = { target: 'screen', screenId: screenId, trigger: null, bodyEl: bodyEl };
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
@@ -68,18 +91,20 @@ function collisionWizardBodyEl() {
|
||||
return _collisionWizard.bodyEl;
|
||||
}
|
||||
|
||||
// Étape 1 — déclencheur (un seul choix pour l'instant, "collision") : la
|
||||
// carte à choix reste affichée telle quelle (plutôt que sautée
|
||||
// automatiquement) pour rester extensible à de futurs types sans rien
|
||||
// changer à la structure de l'assistant.
|
||||
// Étape 1 — déclencheur : "collision"/"clic"/"survol" pour un objet,
|
||||
// "affichage" (seul choix) pour un déclencheur d'écran — la carte à choix
|
||||
// reste affichée telle quelle même à un seul choix (plutôt que sautée
|
||||
// automatiquement) pour rester cohérente d'un parcours à l'autre.
|
||||
function renderCollisionWizardTriggerStep() {
|
||||
var keys = _collisionWizard.target === 'screen' ? ['affichage'] : ['collision', 'clic', 'survol'];
|
||||
var buttons = keys.map(function (key, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseTrigger(\'' + key + '\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS[key] + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseTrigger(trigger) {
|
||||
@@ -90,9 +115,13 @@ function collisionWizardChooseTrigger(trigger) {
|
||||
// Étape 2 (et, pour "interagir", étape 3 en second niveau — `nested`
|
||||
// distingue les deux, "interagir" n'étant proposé qu'au premier niveau,
|
||||
// voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) :
|
||||
// quelle action exécuter.
|
||||
// quelle action exécuter. "interagir" n'a de sens QUE pour "collision"
|
||||
// (bulle "Appuie sur X" tant que le contact dure) — jamais pour "clic"/
|
||||
// "survol"/"affichage", qui n'ont pas cette notion de contact prolongé.
|
||||
function renderCollisionWizardActionStep(nested) {
|
||||
var types = nested ? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_WIZARD_ACTION_TYPES;
|
||||
var types = (nested || _collisionWizard.trigger !== 'collision')
|
||||
? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; })
|
||||
: COLLISION_WIZARD_ACTION_TYPES;
|
||||
var buttons = types.map(function (type, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' +
|
||||
'<span>' + COLLISION_ACTION_LABELS[type] + '</span>' +
|
||||
@@ -121,46 +150,250 @@ function collisionWizardFinalizeAction(action, nested) {
|
||||
collisionWizardSaveRule(action);
|
||||
}
|
||||
|
||||
// Enregistre via la route /game/<slug>/triggers (routes/triggers/triggers.py)
|
||||
// — même contrat "liste ENTIÈRE" que l'ancien éditeur de collision
|
||||
// (recharge les règles existantes de l'objet, y ajoute la nouvelle,
|
||||
// sauvegarde tout), géré côté serveur : le client ne poste que la
|
||||
// nouvelle règle + l'id d'objet.
|
||||
// Enregistre via /game/<slug>/triggers (objet, routes/triggers/triggers.py)
|
||||
// ou /game/<slug>/screens/<id>/triggers (écran, routes/screens/
|
||||
// screen_triggers.py) selon _collisionWizard.target — même contrat
|
||||
// "liste ENTIÈRE" des deux côtés (recharge les règles existantes, y
|
||||
// ajoute la nouvelle, sauvegarde tout), géré côté serveur.
|
||||
function collisionWizardSaveRule(action) {
|
||||
var rule = { trigger: _collisionWizard.trigger, action: action };
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/triggers', {
|
||||
var isScreen = _collisionWizard.target === 'screen';
|
||||
var url = isScreen
|
||||
? '/game/' + GAME_SLUG + '/screens/' + _collisionWizard.screenId + '/triggers'
|
||||
: '/game/' + GAME_SLUG + '/triggers';
|
||||
var body = isScreen ? { rule: rule } : { object_id: _collisionWizard.objectId, rule: rule };
|
||||
fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ object_id: _collisionWizard.objectId, rule: rule }),
|
||||
}).then(function () { triggerLoadAll(); });
|
||||
body: JSON.stringify(body),
|
||||
}).then(function () { isScreen ? screenTriggerLoadAll() : triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// "dialogue"/"variable" (des FEUILLES, voir screens/rendering/
|
||||
// collision_rules.py) enchaînent sur "Ensuite ?" (voir
|
||||
// renderCollisionWizardChainStep — Marquer terminé + chaîner une autre
|
||||
// action, demande explicite : "pouvoir modifier un déclencheur pour lui
|
||||
// ajouter des actions") ; "interagir" enchaîne sur une carte de plus
|
||||
// (voir collisionWizardFinalizeAction) ; "condition" enchaîne sur son
|
||||
// propre mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
// Construit une FEUILLE du type demandé — passe par ses éventuelles
|
||||
// étapes intermédiaires (objet ciblé, valeur, fichier "Mes assets"...)
|
||||
// puis appelle `onBuilt(action)`. PARTAGÉ entre le premier choix
|
||||
// d'action (collisionWizardChooseAction), le choix "Ensuite ?"
|
||||
// (collisionWizardChainDone), les branches Vrai/Faux d'une condition
|
||||
// (collisionWizardChooseBranch) et "+ Ajouter une action" sur un
|
||||
// déclencheur déjà posé (trigger-editor.js::triggerAppendLeaf/
|
||||
// screenTriggerAppendLeaf) — ces quatre points d'entrée ont besoin
|
||||
// EXACTEMENT du même parcours pour chaque type de feuille.
|
||||
function collisionWizardBuildLeafAction(type, onBuilt) {
|
||||
if (type === 'dialogue') {
|
||||
onBuilt({ type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } });
|
||||
return;
|
||||
}
|
||||
if (type === 'variable') {
|
||||
renderCollisionWizardVariableStep(onBuilt);
|
||||
return;
|
||||
}
|
||||
if (type === 'surbrillance' || type === 'visibilite') {
|
||||
renderCollisionWizardObjectTargetStep(function (objectId) {
|
||||
renderCollisionWizardValeurStep(type, function (valeur) {
|
||||
onBuilt({ type: type, id: collisionGenerateActionId(), object_id: objectId, valeur: valeur });
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'son') {
|
||||
renderCollisionWizardAssetStep(function (assetUrl) {
|
||||
onBuilt({ type: 'son', id: collisionGenerateActionId(), asset_url: assetUrl });
|
||||
}, 'audio');
|
||||
return;
|
||||
}
|
||||
if (type === 'video') {
|
||||
renderCollisionWizardAssetStep(function (assetUrl) {
|
||||
renderCollisionWizardVideoModeStep(function (mode) {
|
||||
onBuilt({ type: 'video', id: collisionGenerateActionId(), asset_url: assetUrl, mode: mode });
|
||||
});
|
||||
}, 'video');
|
||||
return;
|
||||
}
|
||||
if (type === 'indication') {
|
||||
renderCollisionWizardObjectTargetStep(function (objectId) {
|
||||
_collisionWizard.pendingIndicationObjectId = objectId;
|
||||
renderCollisionWizardIndicationStep(function (action) { onBuilt(action); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'attendre'
|
||||
renderCollisionWizardAttendreStep(function (secondes) {
|
||||
onBuilt({ type: 'attendre', id: collisionGenerateActionId(), data_value: secondes });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Étape "attendre" (nombre de secondes) ----------
|
||||
function renderCollisionWizardAttendreStep(onChoose) {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Attendre combien de secondes avant de continuer ?</p>' +
|
||||
'<input type="number" id="attendreWizSecondes" min="0" step="0.5" placeholder="2" style="width:100px; margin-bottom:10px;">' +
|
||||
'<button type="button" class="button primary" onclick="collisionWizardAttendreNext()">Continuer</button>';
|
||||
_collisionWizard.onAttendreChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardAttendreNext() {
|
||||
var raw = document.getElementById('attendreWizSecondes').value;
|
||||
var secondes = String(Math.max(0, parseFloat(raw) || 0));
|
||||
var onChoose = _collisionWizard.onAttendreChosen;
|
||||
_collisionWizard.onAttendreChosen = null;
|
||||
onChoose(secondes);
|
||||
}
|
||||
|
||||
// ---------- Étape "Sur quel objet ?" (surbrillance/visibilite/indication) ----------
|
||||
// Réutilise le MÊME catalogue que le choix d'objet propriétaire du
|
||||
// déclencheur (voir triggerRenderObjectStep, trigger-editor.js) : ces
|
||||
// trois actions peuvent cibler N'IMPORTE QUEL objet éligible du jeu, pas
|
||||
// forcément celui qui porte la règle (ex. le joueur touche un
|
||||
// interrupteur -> une indication apparaît près d'une porte ailleurs).
|
||||
function renderCollisionWizardObjectTargetStep(onChoose) {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Sur quel objet ?</p>' +
|
||||
'<div id="collisionWizardObjectTargetGrid" class="collisionWizardChoices">Chargement…</div>';
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/objects-catalog')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (objects) {
|
||||
var grid = document.getElementById('collisionWizardObjectTargetGrid');
|
||||
if (!grid) return;
|
||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||
grid.innerHTML = objects.map(function (o, i) {
|
||||
return '<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardObjectTargetChosen(' + o.id + ')">' +
|
||||
'<div class="questTriggerObjectThumb">' + collisionRuleThumbHtml(o) + '</div>' +
|
||||
'<span>' + (o.name || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
_collisionWizard.onObjectTargetChosen = onChoose;
|
||||
});
|
||||
}
|
||||
|
||||
function collisionWizardObjectTargetChosen(objectId) {
|
||||
var onChoose = _collisionWizard.onObjectTargetChosen;
|
||||
_collisionWizard.onObjectTargetChosen = null;
|
||||
onChoose(objectId);
|
||||
}
|
||||
|
||||
// ---------- Étape "valeur" (surbrillance/visibilite) ----------
|
||||
// SURBRILLANCE_LABELS_MAP/ELEMENT_VISIBILITY_LABELS_MAP posés par
|
||||
// templates/scene_edit.html (voir screens/labels/surbrillance_values.py
|
||||
// et screens/labels/element_visibility.py — mêmes valeurs que le moteur).
|
||||
function renderCollisionWizardValeurStep(actionType, onChoose) {
|
||||
var labelsMap = actionType === 'surbrillance' ? SURBRILLANCE_LABELS_MAP : ELEMENT_VISIBILITY_LABELS_MAP;
|
||||
var keys = Object.keys(labelsMap);
|
||||
var buttons = keys.map(function (key, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardValeurChosen(\'' + key + '\')">' +
|
||||
'<span>' + labelsMap[key] + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + (actionType === 'surbrillance' ? 'Surbrillance :' : 'Visibilité :') + '</p>' +
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
_collisionWizard.onValeurChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardValeurChosen(valeur) {
|
||||
var onChoose = _collisionWizard.onValeurChosen;
|
||||
_collisionWizard.onValeurChosen = null;
|
||||
onChoose(valeur);
|
||||
}
|
||||
|
||||
// ---------- Étape "quel fichier ?" (son/vidéo) ----------
|
||||
// USER_ASSETS_OPTIONS posé par templates/scene_edit.html ([{id, url,
|
||||
// name, kind}], voir auth/user_asset_kind.py) — le créateur doit d'abord
|
||||
// importer un fichier dans "Mes assets" (panneau de gauche) avant de
|
||||
// pouvoir le choisir ici. `kind` ("audio"/"video", demande explicite : "la
|
||||
// vidéo devra être en mp4 ou webm") filtre la liste au bon type — sans
|
||||
// ça, l'action "vidéo" proposait AUSSI les images et les sons (et
|
||||
// inversement pour "son"), un fichier du mauvais type ne pouvant de toute
|
||||
// façon jamais être lu par <video>/<audio> côté jeu.
|
||||
function renderCollisionWizardAssetStep(onChoose, kind) {
|
||||
var assets = USER_ASSETS_OPTIONS.filter(function (a) { return a.kind === kind; });
|
||||
if (!assets.length) {
|
||||
var label = kind === 'video' ? 'vidéo (mp4/webm)' : 'son';
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Aucun fichier ' + label + ' dans "Mes assets" pour l\'instant — importe-en un depuis le panneau de gauche (section "Mes assets"), puis recommence.</p>';
|
||||
return;
|
||||
}
|
||||
var options = assets.map(function (a) {
|
||||
return '<option value="' + a.url + '">' + (a.name || '').replace(/</g, '<') + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Quel fichier (voir "Mes assets") ?</p>' +
|
||||
'<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">' + options + '</select>' +
|
||||
'<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
||||
_collisionWizard.onAssetChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardAssetNext() {
|
||||
var url = document.getElementById('collisionWizardAssetSelect').value;
|
||||
var onChoose = _collisionWizard.onAssetChosen;
|
||||
_collisionWizard.onAssetChosen = null;
|
||||
onChoose(url);
|
||||
}
|
||||
|
||||
// ---------- Étape "comment l'afficher ?" (vidéo) ----------
|
||||
function renderCollisionWizardVideoModeStep(onChoose) {
|
||||
var keys = Object.keys(VIDEO_MODE_LABELS_MAP);
|
||||
var buttons = keys.map(function (key, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardVideoModeChosen(\'' + key + '\')">' +
|
||||
'<span>' + VIDEO_MODE_LABELS_MAP[key] + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Comment l\'afficher ?</p>' +
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
_collisionWizard.onVideoModeChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardVideoModeChosen(mode) {
|
||||
var onChoose = _collisionWizard.onVideoModeChosen;
|
||||
_collisionWizard.onVideoModeChosen = null;
|
||||
onChoose(mode);
|
||||
}
|
||||
|
||||
// ---------- Étape "indication" (texte + durée) ----------
|
||||
// L'objet ciblé a déjà été choisi juste avant (voir
|
||||
// collisionWizardBuildLeafAction) — mémorisé sur le wizard le temps de
|
||||
// cette étape plutôt que reporté en paramètre partout.
|
||||
function renderCollisionWizardIndicationStep(onChoose) {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Texte de la bulle :</p>' +
|
||||
'<input type="text" id="indicationWizText" placeholder="Ex. Clique ici pour continuer" style="width:100%; margin-bottom:8px;">' +
|
||||
'<label class="hint" style="display:block; margin-bottom:10px;">Durée d\'affichage en ms (optionnel, 3000 par défaut) : ' +
|
||||
'<input type="number" id="indicationWizDuree" min="500" step="500" placeholder="3000" style="width:100px;"></label>' +
|
||||
'<button type="button" class="button primary" onclick="collisionWizardIndicationNext()">Continuer</button>';
|
||||
_collisionWizard.onIndicationChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardIndicationNext() {
|
||||
var texte = document.getElementById('indicationWizText').value;
|
||||
var dureeRaw = document.getElementById('indicationWizDuree').value;
|
||||
var action = { type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte: texte };
|
||||
if (dureeRaw) action.duree_ms = parseInt(dureeRaw, 10);
|
||||
_collisionWizard.pendingIndicationObjectId = null;
|
||||
var onChoose = _collisionWizard.onIndicationChosen;
|
||||
_collisionWizard.onIndicationChosen = null;
|
||||
onChoose(action);
|
||||
}
|
||||
|
||||
// "dialogue"/"variable"/"surbrillance"/"visibilite"/"son"/"video"/
|
||||
// "indication" (des FEUILLES, voir screens/rendering/collision_rules.py)
|
||||
// enchaînent sur "Ensuite ?" (voir renderCollisionWizardChainStep —
|
||||
// Marquer terminé + chaîner une autre action, demande explicite :
|
||||
// "pouvoir modifier un déclencheur pour lui ajouter des actions") ;
|
||||
// "interagir" enchaîne sur une carte de plus (voir
|
||||
// collisionWizardFinalizeAction) ; "condition" enchaîne sur son propre
|
||||
// mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
function collisionWizardChooseAction(type, nested) {
|
||||
if (type === 'interagir') {
|
||||
collisionWizardFinalizeAction({ type: type }, nested);
|
||||
return;
|
||||
}
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
return;
|
||||
}
|
||||
if (type === 'variable') {
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'condition') {
|
||||
renderCollisionWizardConditionStep(nested);
|
||||
return;
|
||||
}
|
||||
collisionWizardBuildLeafAction(type, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Étape "Modifier une variable" ----------
|
||||
@@ -216,47 +449,53 @@ function collisionWizardVariableNext() {
|
||||
// chaîner une autre action à la suite (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — jamais bloqué à une seule
|
||||
// action par déclencheur. `onDone(leafAction)` reçoit la PREMIÈRE
|
||||
// feuille de la chaîne (les suivantes sont attachées via `.then`).
|
||||
function renderCollisionWizardChainStep(leafAction, onDone) {
|
||||
// action par déclencheur. `onDone(headAction)` reçoit la PREMIÈRE feuille
|
||||
// de la chaîne (les suivantes sont attachées via `.then`) — `headAction`
|
||||
// (3e paramètre, optionnel) est la TÊTE de la chaîne, transmise SANS
|
||||
// changement à travers chaque ré-appel récursif : `leafAction`, lui,
|
||||
// change à chaque étape (c'est la feuille COURANTE qu'on est en train
|
||||
// d'éditer/étendre). Bug corrigé : sans `headAction` distincte, "Rien de
|
||||
// plus" renvoyait `_collisionWizard.chainLeaf` — qui pointe vers la
|
||||
// DERNIÈRE feuille ajoutée après un premier chaînage, pas la première —
|
||||
// une chaîne "attendre -> puis dialogue" ne sauvegardait donc QUE le
|
||||
// dialogue, "attendre" disparaissait silencieusement.
|
||||
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
||||
headAction = headAction || leafAction;
|
||||
var completionHtml = leafAction.type === 'dialogue'
|
||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">' +
|
||||
'<input type="checkbox" id="condWizMarkCompleted"' + (leafAction.mark_completed ? ' checked' : '') + '> Marquer ce dialogue comme terminé une fois joué' +
|
||||
'</label>'
|
||||
: '';
|
||||
var choiceButtons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + ((i + 1) * 40) + 'ms" onclick="collisionWizardChainDone(\'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
completionHtml +
|
||||
'<p class="hint mb-2">Ensuite ?</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChainDone(\'none\')"><span>Rien de plus</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChainDone(\'dialogue\')"><span>Déclencher un dialogue</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChainDone(\'variable\')"><span>Modifier une variable</span></button>' +
|
||||
choiceButtons +
|
||||
'</div>';
|
||||
_collisionWizard.chainLeaf = leafAction;
|
||||
_collisionWizard.chainHead = headAction;
|
||||
_collisionWizard.onChainDone = onDone;
|
||||
}
|
||||
|
||||
function collisionWizardChainDone(choice) {
|
||||
var leafAction = _collisionWizard.chainLeaf;
|
||||
var headAction = _collisionWizard.chainHead;
|
||||
var onDone = _collisionWizard.onChainDone;
|
||||
if (leafAction.type === 'dialogue') {
|
||||
if (document.getElementById('condWizMarkCompleted').checked) leafAction.mark_completed = true;
|
||||
else delete leafAction.mark_completed;
|
||||
}
|
||||
if (choice === 'none') {
|
||||
onDone(leafAction);
|
||||
onDone(headAction);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var nextLeaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
collisionWizardBuildLeafAction(choice, function (nextLeaf) {
|
||||
leafAction.then = nextLeaf;
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone);
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
leafAction.then = action;
|
||||
renderCollisionWizardChainStep(action, onDone);
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone, headAction);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,16 +542,14 @@ function collisionWizardConditionNext(nested) {
|
||||
// indépendamment l'une de l'autre.
|
||||
function renderCollisionWizardBranchStep(branchKey) {
|
||||
var label = branchKey === 'si_vrai' ? '✓ Si VRAI...' : '✕ Si FAUX...';
|
||||
var choiceButtons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + label + '</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
|
||||
choiceButtons +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:' + (COLLISION_CHAIN_CHOICES.length * 40) + 'ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
|
||||
'<span>Ne rien faire</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
@@ -324,20 +561,11 @@ function collisionWizardChooseBranch(branchKey, choice) {
|
||||
collisionWizardBranchDone(branchKey);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
collisionWizardBuildLeafAction(choice, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// automatiquement sélectionné/ouvert dans le panneau de propriétés (voir
|
||||
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
|
||||
// posé sur le canevas, cliquable ensuite comme n'importe quel autre.
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||||
var body = new FormData();
|
||||
body.append('kind', kind);
|
||||
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
||||
@@ -24,6 +24,10 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
// "Mes assets" (voir auth/user_assets_dir.py) : une image importée par
|
||||
// le créateur ou générée par l'IA, distincte de BACKGROUND_LIBRARY.
|
||||
if (assetId) body.append('asset_id', assetId);
|
||||
// Vrai glisser-déposer (voir onCanvasDrop ci-dessous) : position EXACTE
|
||||
// du dépôt — absent pour un simple clic sur la galerie, qui garde le
|
||||
// défaut habituel (100,100, voir screens/scenes/add_scene_object.py).
|
||||
if (x != null && y != null) { body.append('x', x); body.append('y', y); }
|
||||
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add", {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
})
|
||||
@@ -65,18 +69,113 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// ---------- Vrai glisser-déposer depuis la galerie (demande explicite :
|
||||
// "n'importe où sur la scène", pas seulement un clic qui pose à une
|
||||
// position par défaut) ----------
|
||||
// dataTransfer ne porte que des CHAÎNES : les 4 arguments d'addSceneObject
|
||||
// sont sérialisés en JSON plutôt qu'éclatés en plusieurs champs (certains
|
||||
// sont `null` — JSON les distingue proprement d'une chaîne vide, un
|
||||
// simple attribut HTML ne le pourrait pas). `draggable="true"` sur un
|
||||
// <button> coexiste nativement avec son `onclick` (un clic sans
|
||||
// mouvement déclenche toujours "click", un glisser réel "dragstart" —
|
||||
// jamais les deux à la fois) : le clic simple (pose à la position par
|
||||
// défaut) continue donc de fonctionner exactement comme avant.
|
||||
function onGalleryTileDragStart(ev, kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
ev.dataTransfer.effectAllowed = 'copy';
|
||||
ev.dataTransfer.setData('application/json', JSON.stringify({
|
||||
kind: kind, forgeCharacter: forgeCharacter || null,
|
||||
backgroundSlug: backgroundSlug || null, assetId: assetId || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Un navigateur refuse "drop" par défaut sur TOUT élément — preventDefault()
|
||||
// sur "dragover" est ce qui l'autorise explicitement ici (voir MDN, aucun
|
||||
// contournement plus simple).
|
||||
function onCanvasDragOver(ev) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function onCanvasDrop(ev) {
|
||||
ev.preventDefault();
|
||||
var raw = ev.dataTransfer.getData('application/json');
|
||||
if (!raw) return;
|
||||
var data;
|
||||
try { data = JSON.parse(raw); } catch (e) { return; }
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
// Pas de zoom/échelle CSS sur #canvas (voir templates/scene_edit.html,
|
||||
// juste width/height en px fixes) : une simple soustraction suffit,
|
||||
// aucune division par un facteur d'échelle à appliquer. getBoundingClientRect()
|
||||
// intègre déjà nativement le défilement de .canvasFrame.
|
||||
var x = ev.clientX - rect.left;
|
||||
var y = ev.clientY - rect.top;
|
||||
addSceneObject(data.kind, data.forgeCharacter, data.backgroundSlug, data.assetId, x, y);
|
||||
}
|
||||
|
||||
// "Mes assets" (voir routes/assets/upload_user_asset.py, panneau "Mes
|
||||
// assets" de scene_edit.html) : un simple rechargement après l'upload —
|
||||
// la galerie est rendue côté serveur (user_assets), pas la peine de la
|
||||
// reconstruire en JS pour un flux qui n'arrive pas souvent.
|
||||
function uploadUserAsset(inputEl) {
|
||||
// `sceneKind` ("fond"/"decor", images uniquement — demande explicite :
|
||||
// "un bouton d'import séparé pour les images de fond et les objets") :
|
||||
// décide comment un CLIC sur la tuile résultante pose l'objet (voir
|
||||
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
||||
// qui n'ont rien à "poser".
|
||||
function uploadUserAsset(inputEl, sceneKind) {
|
||||
var file = inputEl.files && inputEl.files[0];
|
||||
if (!file) return;
|
||||
var body = new FormData();
|
||||
body.append('file', file);
|
||||
if (sceneKind) body.append('scene_kind', sceneKind);
|
||||
fetch('/assets/upload', { method: 'POST', body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () { window.location.reload(); })
|
||||
.then(function (r) {
|
||||
// Bug corrigé : un rechargement inconditionnel masquait toute
|
||||
// erreur serveur (ex. son de plus de 3 minutes, voir
|
||||
// auth/validate_audio_duration.py) — le fichier semblait juste
|
||||
// "disparaître" sans explication, jamais ajouté à la galerie.
|
||||
return r.json().then(function (json) { return { ok: r.ok, json: json }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) { alert(result.json.error || "Impossible d'importer ce fichier."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// Suppression d'un fichier de "Mes assets" (demande explicite : "voir,
|
||||
// utiliser ou supprimer des son, image et vidéo") — voir
|
||||
// routes/assets/delete_user_asset.py. Un simple rechargement après coup,
|
||||
// comme uploadUserAsset() juste au-dessus : ce panneau ne se recompose
|
||||
// pas assez souvent pour justifier une suppression ciblée du DOM.
|
||||
function deleteUserAsset(assetId, name) {
|
||||
if (!confirm('Supprimer "' + name + '" ? Cette action est définitive.')) return;
|
||||
fetch('/assets/' + assetId, { method: 'DELETE' })
|
||||
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||
.then(function (result) {
|
||||
if (!result.ok) { alert(result.json.error || "Impossible de supprimer ce fichier."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// "→ Fond"/"→ Décor" (demande diagnostiquée depuis un "décalage" : une
|
||||
// image importée par le mauvais bouton, ou avant la séparation fond/décor,
|
||||
// restait bloquée dans la mauvaise section, impossible à poser comme fond
|
||||
// sans la ré-importer en double) — voir auth/update_user_asset_scene_kind.py.
|
||||
// Ne touche jamais un objet DÉJÀ posé sur une scène : juste la classification
|
||||
// de l'ASSET, pour que la prochaine pose utilise la bonne section/le bon
|
||||
// rendu (object-fit:fill pour un fond, :contain pour un décor).
|
||||
function reclassifyUserAsset(assetId, sceneKind) {
|
||||
fetch('/assets/' + assetId + '/scene-kind', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scene_kind: sceneKind }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||
.then(function (result) {
|
||||
if (!result.ok) { alert(result.json.error || "Reclassement impossible."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
@@ -137,6 +236,24 @@ function saveSceneObjectGeometry(id, x, y, width, height) {
|
||||
return fetch("/game/" + GAME_SLUG + "/scene-objects/" + id + "/geometry", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ x: x, y: y, width: width, height: height }),
|
||||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||
// Bug corrigé (même famille que iaChatRefreshAfterTurn(), voir
|
||||
// static/js/ia/ia-chat.js) : un "fond" redimensionné/déplacé à la
|
||||
// souris au-delà du champ de la caméra doit élargir le "monde" tout
|
||||
// de suite — sans ça, window.SCENE_WIDTH/SCENE_HEIGHT (posées une
|
||||
// seule fois à l'ouverture de la page) restaient à leur ancienne
|
||||
// valeur et le glisser-déposer continuait de tout borner à l'ancien
|
||||
// cadre, rendant injoignable à la souris tout ce qui dépassait
|
||||
// désormais la scène nominale.
|
||||
if (data && typeof data.world_width === 'number' && typeof data.world_height === 'number') {
|
||||
window.SCENE_WIDTH = data.world_width;
|
||||
window.SCENE_HEIGHT = data.world_height;
|
||||
var canvas = document.getElementById('canvas');
|
||||
if (canvas) {
|
||||
canvas.style.width = data.world_width + 'px';
|
||||
canvas.style.height = data.world_height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,35 @@ function triggerColumnHtml() {
|
||||
// (pastilles/carte objet), avec un bouton "Réplique"/"Question" qui
|
||||
// ajoute une bulle directement dedans.
|
||||
|
||||
// Voix off d'une réplique (demande explicite : "ajouter une réplique
|
||||
// audio") — un fichier déjà présent dans "Mes assets" (voir
|
||||
// USER_ASSETS_OPTIONS, posé par templates/scene_edit.html), jamais une
|
||||
// URL tapée à la main : filtré aux sons (kind="audio", voir
|
||||
// auth/user_asset_kind.py) — un choix de son ou de vidéo n'a pas de sens
|
||||
// ici.
|
||||
function triggerAudioAssetOptions() {
|
||||
return (typeof USER_ASSETS_OPTIONS !== 'undefined' ? USER_ASSETS_OPTIONS : []).filter(function (a) { return a.kind === 'audio'; });
|
||||
}
|
||||
|
||||
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
||||
var assetOptions = triggerAudioAssetOptions();
|
||||
var selectOptions = '<option value="">Aucune voix off</option>' + assetOptions.map(function (a) {
|
||||
return '<option value="' + a.url.replace(/"/g, '"') + '"' + (a.url === audioUrl ? ' selected' : '') + '>' + a.name.replace(/</g, '<') + '</option>';
|
||||
}).join('');
|
||||
var preview = audioUrl
|
||||
? '<audio controls preload="none" src="' + audioUrl.replace(/"/g, '"') + '" class="questBubbleAudioPreview"></audio>'
|
||||
: '';
|
||||
return (
|
||||
'<div class="questBubbleAudioRow">' +
|
||||
'<select class="questBubbleAudioSelect" title="Voix off (optionnel)" onchange="triggerInlineSetBubbleAudio(\'' + dialogueId + '\', ' + index + ', this.value)">' + selectOptions + '</select>' +
|
||||
preview +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Une bulle = header (qui parle, menu déroulant des objets de scène
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
|
||||
// distinctes, pas un seul bloc.
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte + la voix off
|
||||
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
var kind = triggerBubbleKind(line.speaker);
|
||||
var speakerSelect = triggerSpeakerOptions(line.speaker).map(function (name) {
|
||||
@@ -82,6 +108,7 @@ function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="triggerInlineUpdateBubbleText(\'' + dialogueId + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url) +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
@@ -160,27 +187,154 @@ function triggerVariableActionHtml(item) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Feuilles "surbrillance"/"visibilite"/"son"/"video"/
|
||||
// "indication" (voir screens/rendering/collision_rules.py) — même
|
||||
// encart compact que "Modifier une variable" ci-dessus, un simple texte
|
||||
// descriptif (rien à éditer en ligne, contrairement au dialogue).
|
||||
var COLLISION_LEAF_PILL_LABELS = {
|
||||
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
|
||||
surbrillance: 'Mettre en surbrillance', visibilite: 'Rendre visible/invisible',
|
||||
son: 'Jouer un son', video: 'Jouer une vidéo', indication: 'Afficher une indication',
|
||||
attendre: 'Attendre quelques secondes',
|
||||
};
|
||||
|
||||
function triggerItemLeafId(item) {
|
||||
return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id;
|
||||
}
|
||||
|
||||
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
|
||||
// ou écran (routes/screens/screen_triggers.py, toujours CURRENT_SCREEN_ID,
|
||||
// aucun `targetId` à connaître).
|
||||
function triggerChainActionUrl(target, targetId, leafId, suffix) {
|
||||
return target === 'screen'
|
||||
? '/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/action/' + encodeURIComponent(leafId) + suffix
|
||||
: '/game/' + GAME_SLUG + '/triggers/' + targetId + '/action/' + encodeURIComponent(leafId) + suffix;
|
||||
}
|
||||
|
||||
function triggerReloadFor(target) {
|
||||
if (target === 'screen') screenTriggerLoadAll();
|
||||
else triggerLoadAll();
|
||||
}
|
||||
|
||||
// Demande explicite : "que chaque bloc étiquette + action soit
|
||||
// déplaçable ou supprimable par l'utilisateur et par l'IA aussi" — voir
|
||||
// screens/rendering/collision_rules.py::remove_action_from_rules/
|
||||
// move_action_in_rules, déjà utilisables par Ruby via remove_trigger_action/
|
||||
// move_trigger_action (voir ai/tools.py).
|
||||
function triggerRemoveChainAction(target, targetId, leafId) {
|
||||
if (!confirm('Retirer cette action ?')) return;
|
||||
fetch(triggerChainActionUrl(target, targetId, leafId, ''), { method: 'DELETE' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible de retirer cette action."); });
|
||||
})
|
||||
.then(function () { triggerReloadFor(target); });
|
||||
}
|
||||
|
||||
function triggerMoveChainAction(target, targetId, leafId, direction) {
|
||||
fetch(triggerChainActionUrl(target, targetId, leafId, '/move'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction: direction }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible de déplacer cette action."); });
|
||||
}).then(function () { triggerReloadFor(target); });
|
||||
}
|
||||
|
||||
// `target`/`targetId` absents (undefined) : pastille sans contrôles —
|
||||
// utilisé pour "interagir" lui-même (pas une feuille adressable, voir
|
||||
// _leaf_id côté serveur) dans triggerChainHtml ci-dessous.
|
||||
function triggerLeafPillHtml(item, target, targetId, index, total) {
|
||||
var label = COLLISION_LEAF_PILL_LABELS[item.type] || item.type;
|
||||
var leafId = target && triggerItemLeafId(item);
|
||||
var controls = '';
|
||||
if (leafId) {
|
||||
var targetIdArg = targetId == null ? 'null' : targetId;
|
||||
controls =
|
||||
'<span class="questTriggerPillControls">' +
|
||||
'<button type="button" title="Monter" class="questTriggerPillCtrlBtn"' + (index === 0 ? ' disabled' : '') +
|
||||
' onclick="event.stopPropagation(); triggerMoveChainAction(\'' + target + '\', ' + targetIdArg + ', \'' + leafId + '\', \'up\')">↑</button>' +
|
||||
'<button type="button" title="Descendre" class="questTriggerPillCtrlBtn"' + (index === total - 1 ? ' disabled' : '') +
|
||||
' onclick="event.stopPropagation(); triggerMoveChainAction(\'' + target + '\', ' + targetIdArg + ', \'' + leafId + '\', \'down\')">↓</button>' +
|
||||
'<button type="button" title="Retirer cette action" class="questTriggerPillCtrlBtn questTriggerPillCtrlBtn-delete" ' +
|
||||
'onclick="event.stopPropagation(); triggerRemoveChainAction(\'' + target + '\', ' + targetIdArg + ', \'' + leafId + '\')">✕</button>' +
|
||||
'</span>';
|
||||
}
|
||||
return '<div class="questTriggerPill quete">' + label + controls + '</div>';
|
||||
}
|
||||
|
||||
function triggerAssetFilename(url) {
|
||||
return ((url || '').split('/').pop() || url || '').replace(/</g, '<');
|
||||
}
|
||||
|
||||
function triggerGenericLeafBoxHtml(title, text) {
|
||||
return (
|
||||
'<div class="dialogueBox questVariableAction">' +
|
||||
'<div class="dialogueBoxHeader"><span class="dialogueBoxTitle">' + title + '</span></div>' +
|
||||
'<p class="questVariableActionLabel">' + text + '</p>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function triggerLeafBoxHtml(item) {
|
||||
if (item.type === 'dialogue') return triggerInlineDialogueHtml(item.dialogue);
|
||||
if (item.type === 'variable') return triggerVariableActionHtml(item);
|
||||
if (item.type === 'surbrillance') {
|
||||
var sLabel = (typeof SURBRILLANCE_LABELS_MAP !== 'undefined' && SURBRILLANCE_LABELS_MAP[item.valeur]) || item.valeur;
|
||||
return triggerGenericLeafBoxHtml('Surbrillance', 'Objet #' + item.object_id + ' — ' + sLabel);
|
||||
}
|
||||
if (item.type === 'visibilite') {
|
||||
var vLabel = (typeof ELEMENT_VISIBILITY_LABELS_MAP !== 'undefined' && ELEMENT_VISIBILITY_LABELS_MAP[item.valeur]) || item.valeur;
|
||||
return triggerGenericLeafBoxHtml('Visibilité', 'Objet #' + item.object_id + ' — ' + vLabel);
|
||||
}
|
||||
if (item.type === 'son') {
|
||||
return triggerGenericLeafBoxHtml('Son', triggerAssetFilename(item.asset_url));
|
||||
}
|
||||
if (item.type === 'video') {
|
||||
var mLabel = (typeof VIDEO_MODE_LABELS_MAP !== 'undefined' && VIDEO_MODE_LABELS_MAP[item.mode]) || item.mode;
|
||||
return triggerGenericLeafBoxHtml('Vidéo', triggerAssetFilename(item.asset_url) + ' — ' + mLabel);
|
||||
}
|
||||
if (item.type === 'indication') {
|
||||
return triggerGenericLeafBoxHtml('Indication', 'Objet #' + item.object_id + ' — ' + (item.texte || '').replace(/</g, '<'));
|
||||
}
|
||||
if (item.type === 'attendre') {
|
||||
return triggerGenericLeafBoxHtml('Attendre', (item.data_value || '0') + ' seconde(s)');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
|
||||
// _describe_chain) : "Déclencher ce dialogue" + son rectangle, ou une
|
||||
// feuille "variable", à la suite les unes des autres — PUIS, en bout de
|
||||
// chaîne, "+ Ajouter une action" (demande explicite : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions", jamais bloqué à une
|
||||
// seule action). `objectId` : nécessaire pour poster l'ajout sur le bon
|
||||
// objet (voir triggerOpenAppendActionModal).
|
||||
function triggerActionChainHtml(chain, objectId) {
|
||||
// _describe_chain) : chaque feuille (dialogue, variable, ou l'une des
|
||||
// nouvelles actions ci-dessus) à la suite les unes des autres — PUIS, en
|
||||
// bout de chaîne, "+ Ajouter une action" (demande explicite : "pouvoir
|
||||
// modifier un déclencheur pour lui ajouter des actions", jamais bloqué à
|
||||
// une seule action). `target`/`targetId` : "object"/l'id de l'objet
|
||||
// (routes/triggers/triggers.py) ou "screen"/null (routes/screens/
|
||||
// screen_triggers.py, voir screenTriggerOpenAppendActionModal) — décide
|
||||
// où poster l'ajout.
|
||||
// "+ Ajouter une action" APRÈS `afterId` — insère (voir
|
||||
// screens/rendering/collision_rules.py::set_then_action_in_rules,
|
||||
// demande explicite : "pouvoir ajouter n'importe quelle action à
|
||||
// n'importe quel niveau du schéma") plutôt que d'exiger d'être en bout
|
||||
// de chaîne réel : posé après CHAQUE bloc (voir triggerActionChainHtml),
|
||||
// pas seulement le dernier.
|
||||
function triggerAppendButtonHtml(target, targetId, afterId) {
|
||||
if (!afterId) return '';
|
||||
var appendOnclick = target === 'screen'
|
||||
? 'screenTriggerOpenAppendActionModal(\'' + afterId + '\')'
|
||||
: 'triggerOpenAppendActionModal(' + targetId + ', \'' + afterId + '\')';
|
||||
return '<div class="questTriggerConnector"></div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="' + appendOnclick + '">+ Ajouter une action</button>';
|
||||
}
|
||||
|
||||
function triggerActionChainHtml(chain, target, targetId) {
|
||||
if (!chain || !chain.length) return '';
|
||||
var itemsHtml = chain.map(function (item) {
|
||||
var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '<div class="questTriggerPill quete">Modifier une variable</div>';
|
||||
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
|
||||
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
|
||||
return chain.map(function (item, i) {
|
||||
var leafId = triggerItemLeafId(item);
|
||||
return (
|
||||
'<div class="questTriggerConnector"></div>' + triggerLeafPillHtml(item, target, targetId, i, chain.length) +
|
||||
'<div class="questTriggerConnector"></div>' + triggerLeafBoxHtml(item) +
|
||||
triggerAppendButtonHtml(target, targetId, leafId)
|
||||
);
|
||||
}).join('');
|
||||
var lastItem = chain[chain.length - 1];
|
||||
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
|
||||
var appendBtn = lastLeafId
|
||||
? '<div class="questTriggerConnector"></div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
|
||||
: '';
|
||||
return itemsHtml + appendBtn;
|
||||
}
|
||||
|
||||
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
|
||||
@@ -259,15 +413,15 @@ function triggerChainHtml(trigger) {
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }];
|
||||
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS[trigger.trigger] || trigger.trigger }];
|
||||
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
|
||||
var pillsHtml = pills.map(function (pill, i) {
|
||||
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
|
||||
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
|
||||
}).join('');
|
||||
var tailHtml = trigger.condition
|
||||
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
|
||||
: triggerActionChainHtml(trigger.chain, trigger.object_id);
|
||||
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, 'object', trigger.object_id)
|
||||
: triggerActionChainHtml(trigger.chain, 'object', trigger.object_id);
|
||||
return (
|
||||
'<div class="questTriggerCol' + (isActive ? ' active' : '') + '">' +
|
||||
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
|
||||
@@ -311,7 +465,7 @@ function triggerCondArrowHtml(kind, label) {
|
||||
);
|
||||
}
|
||||
|
||||
function triggerConditionColumnHtml(condition, objectId) {
|
||||
function triggerConditionColumnHtml(condition, target, targetId) {
|
||||
var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
||||
var label = (condition.variable || '').replace(/</g, '<') + ' ' + (opLabel || '').replace(/</g, '<') + ' ' + (condition.valeur || '').replace(/</g, '<');
|
||||
var branchesHtml = ['si_vrai', 'si_faux'].map(function (branchKey) {
|
||||
@@ -319,7 +473,7 @@ function triggerConditionColumnHtml(condition, objectId) {
|
||||
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||
var branch = condition[branchKey];
|
||||
var content = branch
|
||||
? triggerActionChainHtml(branch.chain, objectId)
|
||||
? triggerActionChainHtml(branch.chain, target, targetId)
|
||||
: '<div class="questTriggerPill none">Ne rien faire</div>';
|
||||
return triggerCondArrowHtml(kind, arrowLabel) + content;
|
||||
}).join('');
|
||||
@@ -338,35 +492,51 @@ function triggerConditionColumnHtml(condition, objectId) {
|
||||
// tapée — l'adopter effacerait silencieusement la bulle en cours
|
||||
// d'écriture).
|
||||
var _triggersCache = [];
|
||||
// dialogue_id -> {objectId, dialogueRef} — `dialogueRef` est LA MÊME
|
||||
// référence que dans _triggersCache, muter dialogueRef.lines ici mute
|
||||
// donc aussi le cache (une seule source de vérité). Couvre aussi bien le
|
||||
// dialogue direct d'un déclencheur que ceux embarqués dans ses branches
|
||||
// de condition.
|
||||
// dialogue_id -> {objectId}|{screenId} + dialogueRef — `dialogueRef` est
|
||||
// LA MÊME référence que dans _triggersCache/_screenTriggersCache, muter
|
||||
// dialogueRef.lines ici mute donc aussi le cache (une seule source de
|
||||
// vérité). Couvre aussi bien le dialogue direct d'un déclencheur que
|
||||
// ceux embarqués dans ses branches de condition — PARTAGÉ entre les
|
||||
// déclencheurs d'OBJET et D'ÉCRAN (voir _registerScreenTriggerDialogues),
|
||||
// une seule table pour les deux.
|
||||
var _triggerDialogueRegistry = {};
|
||||
|
||||
function _registerChainDialogues(chain, objectId, dialogueIds) {
|
||||
// `owner` : {objectId: N} ou {screenId: N} — L'UN OU L'AUTRE, jamais les
|
||||
// deux (voir triggerInlineSaveDialogue, qui route sur cette distinction).
|
||||
function _registerChainDialogues(chain, owner, dialogueIds) {
|
||||
(chain || []).forEach(function (item) {
|
||||
if (item.type === 'dialogue' && item.dialogue) {
|
||||
_triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue };
|
||||
_triggerDialogueRegistry[item.dialogue.id] = Object.assign({ dialogueRef: item.dialogue }, owner);
|
||||
dialogueIds.push(item.dialogue.id);
|
||||
} else if (item.type === 'variable' && item.id) {
|
||||
} else if (item.id) {
|
||||
dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Efface uniquement les entrées d'UN propriétaire (objet OU écran, voir
|
||||
// `key` = 'objectId'/'screenId') avant de les reconstruire — jamais tout
|
||||
// le registre, sans quoi recharger les déclencheurs d'objet effacerait
|
||||
// au passage les entrées des déclencheurs d'écran déjà enregistrées (et
|
||||
// inversement), les deux rechargements étant indépendants l'un de
|
||||
// l'autre.
|
||||
function _clearRegistryEntriesWithKey(key) {
|
||||
Object.keys(_triggerDialogueRegistry).forEach(function (id) {
|
||||
if (Object.prototype.hasOwnProperty.call(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
|
||||
});
|
||||
}
|
||||
|
||||
function _registerTriggerDialogues(list) {
|
||||
_triggerDialogueRegistry = {};
|
||||
_clearRegistryEntriesWithKey('objectId');
|
||||
var dialogueIds = [];
|
||||
list.forEach(function (t) {
|
||||
if (t.condition) {
|
||||
['si_vrai', 'si_faux'].forEach(function (branchKey) {
|
||||
var branch = t.condition[branchKey];
|
||||
if (branch) _registerChainDialogues(branch.chain, t.object_id, dialogueIds);
|
||||
if (branch) _registerChainDialogues(branch.chain, { objectId: t.object_id }, dialogueIds);
|
||||
});
|
||||
} else {
|
||||
_registerChainDialogues(t.chain, t.object_id, dialogueIds);
|
||||
_registerChainDialogues(t.chain, { objectId: t.object_id }, dialogueIds);
|
||||
}
|
||||
});
|
||||
return dialogueIds;
|
||||
@@ -408,7 +578,13 @@ function triggerDeleteTrigger(objectId) {
|
||||
function triggerInlineSaveDialogue(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', {
|
||||
// "screenId" (déclencheur d'écran, routes/screens/screen_triggers.py)
|
||||
// ou "objectId" (déclencheur d'objet, routes/triggers/triggers.py) —
|
||||
// l'UN OU L'AUTRE, jamais les deux (voir _registerChainDialogues).
|
||||
var url = entry.screenId != null
|
||||
? '/game/' + GAME_SLUG + '/screens/' + entry.screenId + '/triggers/dialogue'
|
||||
: '/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue';
|
||||
fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dialogue_id: dialogueId, lines: entry.dialogueRef.lines }),
|
||||
}).then(function (r) {
|
||||
@@ -425,13 +601,25 @@ function triggerNextDefaultSpeaker(lastSpeaker) {
|
||||
return 'Joueur';
|
||||
}
|
||||
|
||||
// Réaffiche le BON panneau après une mutation en ligne — un dialogue
|
||||
// d'écran (entry.screenId) vit dans #screenTriggersSlot
|
||||
// (screenTriggerRenderSlot), un dialogue d'objet (entry.objectId) dans
|
||||
// #triggersSlot (triggerRenderSlot) : rafraîchir systématiquement
|
||||
// triggerRenderSlot() laissait le panneau "Écran" affiché figé après
|
||||
// Réplique/Question (bug corrigé — la sauvegarde serveur fonctionnait
|
||||
// bien, seul le rendu restait périmé).
|
||||
function _triggerRenderSlotForEntry(entry) {
|
||||
if (entry.screenId != null) screenTriggerRenderSlot();
|
||||
else triggerRenderSlot();
|
||||
}
|
||||
|
||||
function triggerInlineAddBubble(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
var lines = entry.dialogueRef.lines;
|
||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
|
||||
lines.push({ type: 'dialogue', speaker: triggerNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -442,7 +630,7 @@ function triggerInlineAddQuestion(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -466,7 +654,7 @@ function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
|
||||
while (line.choices.length < count) line.choices.push('');
|
||||
while (line.choices.length > count) line.choices.pop();
|
||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -495,7 +683,16 @@ function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].speaker = speaker;
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetBubbleAudio(dialogueId, index, audioUrl) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
if (audioUrl) entry.dialogueRef.lines[index].audio_url = audioUrl;
|
||||
else delete entry.dialogueRef.lines[index].audio_url;
|
||||
_triggerRenderSlotForEntry(entry); // ré-affiche l'aperçu <audio> (apparaît/disparaît selon le choix)
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -510,7 +707,7 @@ function triggerInlineDeleteBubble(dialogueId, index) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.splice(index, 1);
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -580,8 +777,8 @@ function triggerSelectObject(objectId) {
|
||||
// des actions" — pas besoin de repasser par la modale de création, on
|
||||
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
|
||||
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
|
||||
// choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner
|
||||
// encore), avant d'envoyer le tout via /append-action.
|
||||
// même choix de feuille (COLLISION_CHAIN_CHOICES) que l'étape "Ensuite ?"
|
||||
// de l'assistant, avant d'envoyer le tout via /append-action.
|
||||
function triggerOpenAppendActionModal(objectId, afterId) {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
@@ -589,28 +786,17 @@ function triggerOpenAppendActionModal(objectId, afterId) {
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
|
||||
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
|
||||
// renderCollisionWizardVariableStep — elles ne lisent que .bodyEl).
|
||||
// collisionWizardBuildLeafAction — elles ne lisent que .bodyEl).
|
||||
_collisionWizard = { bodyEl: body };
|
||||
body.innerHTML =
|
||||
'<p class="hint mb-2">Ajouter...</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
var buttons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
|
||||
}).join('');
|
||||
body.innerHTML = '<p class="hint mb-2">Ajouter...</p><div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
}
|
||||
|
||||
function triggerAppendLeaf(objectId, afterId, type) {
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
collisionWizardBuildLeafAction(type, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
return;
|
||||
}
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,6 +815,150 @@ function closeTriggerModal() {
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
// ---------- Section "Déclencheurs de l'écran" (SANS objet, voir
|
||||
// screens/rendering/screen_triggers.py) ----------
|
||||
// Même moteur d'affichage que les déclencheurs d'objet ci-dessus
|
||||
// (triggerActionChainHtml/triggerConditionColumnHtml, target='screen'),
|
||||
// mais une liste PLATE de règles (pas de carte objet, pas d'accordéon —
|
||||
// il n'y a qu'un seul "propriétaire" possible : l'écran lui-même).
|
||||
// screenActionToChain/screenTriggerNormalize reproduisent CÔTÉ CLIENT le
|
||||
// même aplatissement que screens/rendering/trigger_graph.py::
|
||||
// _resolve_action_trigger fait côté serveur pour les déclencheurs
|
||||
// d'objet (jamais de condition imbriquée dans un "then", voir
|
||||
// _sanitize_leaf_action — la boucle s'arrête donc forcément sur le
|
||||
// premier "condition" rencontré, toujours en position de tête).
|
||||
function collisionLeafChain(leafAction) {
|
||||
var chain = [];
|
||||
var node = leafAction;
|
||||
while (node) { chain.push(node); node = node.then; }
|
||||
return chain;
|
||||
}
|
||||
|
||||
function screenTriggerNormalize(rule) {
|
||||
if (rule.action.type === 'condition') {
|
||||
var cond = rule.action;
|
||||
return {
|
||||
chain: null,
|
||||
condition: {
|
||||
variable: cond.variable, operateur: cond.operateur, valeur: cond.valeur,
|
||||
si_vrai: cond.si_vrai ? { chain: collisionLeafChain(cond.si_vrai) } : null,
|
||||
si_faux: cond.si_faux ? { chain: collisionLeafChain(cond.si_faux) } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { chain: collisionLeafChain(rule.action), condition: null };
|
||||
}
|
||||
|
||||
var _screenTriggersCache = [];
|
||||
|
||||
function screenTriggerColumnHtml() {
|
||||
return (
|
||||
'<div class="questDialogueColumn">' +
|
||||
'<div class="questDialogueColumnBody" id="screenTriggerColumnBody">' +
|
||||
'<div class="questTriggersSlot" id="screenTriggersSlot"></div>' +
|
||||
'</div>' +
|
||||
'<div class="questDialogueColumnButtons">' +
|
||||
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openScreenAddTriggerModal()">Ajouter un déclencheur d\'écran</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function screenTriggerRuleHtml(rule, index) {
|
||||
var normalized = screenTriggerNormalize(rule);
|
||||
var tailHtml = normalized.condition
|
||||
? triggerConditionColumnHtml(normalized.condition, 'screen', null)
|
||||
: triggerActionChainHtml(normalized.chain, 'screen', null);
|
||||
return (
|
||||
'<div class="questTriggerCol">' +
|
||||
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" onclick="screenTriggerDeleteAt(' + index + ')">✕</button>' +
|
||||
'<div class="questTriggerPill trigger">' + (COLLISION_TRIGGER_LABELS[rule.trigger] || rule.trigger) + '</div>' +
|
||||
'<div class="questTriggerConnector"></div>' +
|
||||
tailHtml +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function screenTriggerRenderSlot() {
|
||||
var slot = document.getElementById('screenTriggersSlot');
|
||||
if (!slot) return;
|
||||
slot.innerHTML = _screenTriggersCache.length
|
||||
? _screenTriggersCache.map(function (rule, i) { return screenTriggerRuleHtml(rule, i); }).join('')
|
||||
: '<p class="hint">Aucun déclencheur d\'écran pour l\'instant.</p>';
|
||||
}
|
||||
|
||||
function _registerScreenTriggerDialogues(list) {
|
||||
_clearRegistryEntriesWithKey('screenId');
|
||||
var dialogueIds = [];
|
||||
list.forEach(function (rule) {
|
||||
var normalized = screenTriggerNormalize(rule);
|
||||
if (normalized.condition) {
|
||||
['si_vrai', 'si_faux'].forEach(function (branchKey) {
|
||||
var branch = normalized.condition[branchKey];
|
||||
if (branch) _registerChainDialogues(branch.chain, { screenId: CURRENT_SCREEN_ID }, dialogueIds);
|
||||
});
|
||||
} else {
|
||||
_registerChainDialogues(normalized.chain, { screenId: CURRENT_SCREEN_ID }, dialogueIds);
|
||||
}
|
||||
});
|
||||
return dialogueIds;
|
||||
}
|
||||
|
||||
function screenTriggerLoadAll() {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) {
|
||||
_screenTriggersCache = list;
|
||||
_registerScreenTriggerDialogues(list);
|
||||
screenTriggerRenderSlot();
|
||||
});
|
||||
}
|
||||
|
||||
function screenTriggerDeleteAt(index) {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/' + index, { method: 'DELETE' })
|
||||
.then(function () { screenTriggerLoadAll(); });
|
||||
}
|
||||
|
||||
function openScreenAddTriggerModal() {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur d\'écran';
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
openScreenTriggerWizard(CURRENT_SCREEN_ID, body);
|
||||
}
|
||||
|
||||
// "+ Ajouter une action" en bout de chaîne d'un déclencheur d'écran déjà
|
||||
// posé — même parcours que triggerOpenAppendActionModal ci-dessus, juste
|
||||
// adressé vers /screens/<id>/triggers/append-action (aucun objet à
|
||||
// connaître).
|
||||
function screenTriggerOpenAppendActionModal(afterId) {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
_collisionWizard = { bodyEl: body };
|
||||
var buttons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="screenTriggerAppendLeaf(\'' + afterId + '\', \'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
|
||||
}).join('');
|
||||
body.innerHTML = '<p class="hint mb-2">Ajouter...</p><div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
}
|
||||
|
||||
function screenTriggerAppendLeaf(afterId, type) {
|
||||
collisionWizardBuildLeafAction(type, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { screenTriggerSaveAppendedAction(afterId, finalLeaf); });
|
||||
});
|
||||
}
|
||||
|
||||
function screenTriggerSaveAppendedAction(afterId, action) {
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/append-action', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ after_id: afterId, action: action }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'ajouter cette action."); });
|
||||
}).then(function () { screenTriggerLoadAll(); });
|
||||
}
|
||||
|
||||
// ---------- Point d'entrée ----------
|
||||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||||
@@ -639,10 +969,46 @@ function closeTriggerModal() {
|
||||
// l'onglet "Déclencheurs" restait vide après un remplacement de <main>
|
||||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
||||
// ne redéclenche jamais cet événement.
|
||||
// Menu latéral (demande explicite) : "🧩 Objets" (déclencheurs posés sur
|
||||
// un objet de scène) / "🖥️ Écran" (voir screens/rendering/
|
||||
// screen_triggers.py, SANS objet requis) — un seul panneau visible à la
|
||||
// fois, plutôt que les deux empilés l'un sous l'autre.
|
||||
var _triggersTabMode = 'object';
|
||||
|
||||
function triggersTabSwitchMode(mode) {
|
||||
_triggersTabMode = mode;
|
||||
var objectItem = document.getElementById('triggersTabSidebarObject');
|
||||
var screenItem = document.getElementById('triggersTabSidebarScreen');
|
||||
var objectPane = document.getElementById('triggersObjectPane');
|
||||
var screenPane = document.getElementById('triggersScreenPane');
|
||||
if (!objectItem || !screenItem || !objectPane || !screenPane) return;
|
||||
objectItem.classList.toggle('active', mode === 'object');
|
||||
screenItem.classList.toggle('active', mode === 'screen');
|
||||
objectPane.style.display = mode === 'object' ? 'flex' : 'none';
|
||||
screenPane.style.display = mode === 'screen' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function initTriggersTab() {
|
||||
var body = document.getElementById('triggersTabPanelBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
|
||||
body.innerHTML =
|
||||
'<div class="triggersTabLayout">' +
|
||||
'<div class="triggersTabSidebar">' +
|
||||
'<button type="button" id="triggersTabSidebarObject" class="triggersTabSidebarItem active" onclick="triggersTabSwitchMode(\'object\')">Objets</button>' +
|
||||
'<button type="button" id="triggersTabSidebarScreen" class="triggersTabSidebarItem" onclick="triggersTabSwitchMode(\'screen\')">Écran</button>' +
|
||||
'</div>' +
|
||||
'<div class="triggersTabContent">' +
|
||||
'<div id="triggersObjectPane" class="triggersTabPane">' +
|
||||
'<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>' +
|
||||
'</div>' +
|
||||
'<div id="triggersScreenPane" class="triggersTabPane" style="display:none;">' +
|
||||
'<p class="hint triggersTabPaneIntro">Se déclenchent SANS objet — narration/cinématique dès l\'affichage de cet écran.</p>' +
|
||||
'<div class="questDialogueColumns">' + screenTriggerColumnHtml() + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
triggersTabSwitchMode(_triggersTabMode); // conserve l'onglet actif d'un ré-affichage à l'autre (pjax, sélection d'objet...)
|
||||
triggerLoadAll();
|
||||
screenTriggerLoadAll();
|
||||
}
|
||||
initTriggersTab();
|
||||
|
||||
+127
-1
@@ -608,6 +608,45 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
reconnaissable, contrairement à une icône. */
|
||||
.iconGallery.personnageGallery{ grid-template-columns:repeat(2, 1fr); gap:10px; max-height:420px; }
|
||||
.personnageThumb{ width:100%; height:100%; object-fit:contain; image-rendering:pixelated; }
|
||||
|
||||
/* "Mes assets" — sous-sections Images/Sons/Vidéos (demande explicite :
|
||||
"voir, utiliser ou supprimer des son, image et vidéo") : une tuile
|
||||
image garde son bouton de pose (clic/glisser-déposer, voir
|
||||
iconGalleryTile) avec un ✕ de suppression posé par dessus ; un son/une
|
||||
vidéo n'ont rien à "poser" — juste une rangée avec leur lecteur natif
|
||||
(aperçu direct, "voir"/"utiliser" au sens d'écouter/visionner avant de
|
||||
le choisir dans le sélecteur d'une action) et le même ✕. */
|
||||
.userAssetTileWrap{ position:relative; }
|
||||
.userAssetTileWrap .iconGalleryTile{ width:100%; height:100%; }
|
||||
.userAssetDeleteBtn{
|
||||
position:absolute; top:-6px; right:-6px; width:18px; height:18px; border-radius:50%;
|
||||
border:1px solid var(--border); background:#1a1d24; color:var(--muted, #8b95a7);
|
||||
font-size:10px; line-height:1; display:flex; align-items:center; justify-content:center; cursor:pointer;
|
||||
padding:0; z-index:2;
|
||||
}
|
||||
.userAssetDeleteBtn:hover{ background:#e0524d; color:#fff; border-color:#e0524d; }
|
||||
.userAssetRow{
|
||||
display:flex; flex-direction:column; gap:4px; padding:8px; margin-top:8px;
|
||||
background:var(--panel2); border:1px solid var(--border); border-radius:8px;
|
||||
}
|
||||
.userAssetRow:first-child{ margin-top:0; }
|
||||
.userAssetRow audio, .userAssetRow video{ width:100%; display:block; border-radius:4px; background:#000; }
|
||||
.userAssetRowFooter{ display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.userAssetName{ font-size:11.5px; color:var(--text-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
|
||||
.userAssetRowFooter .userAssetDeleteBtn{ position:static; flex-shrink:0; }
|
||||
/* "→ Fond"/"→ Décor" (voir auth/update_user_asset_scene_kind.py) : bascule
|
||||
une image DÉJÀ importée entre "Fonds" et "Décors/objets" sans ré-upload
|
||||
— trouvé en diagnostiquant un "décalage" (une image importée par le
|
||||
mauvais bouton, ou avant la séparation fond/décor, restait bloquée dans
|
||||
la mauvaise section). Posé dans .userAssetRowFooter (déjà en
|
||||
position:static, voir ci-dessus), sous la vignette d'une image. */
|
||||
.userAssetTileWrap .userAssetRowFooter{ margin-top:4px; }
|
||||
.userAssetReclassifyBtn{
|
||||
flex:1; font-size:10.5px; padding:2px 4px; border-radius:4px;
|
||||
border:1px solid var(--border); background:var(--panel2); color:var(--text-dim);
|
||||
cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
}
|
||||
.userAssetReclassifyBtn:hover{ background:var(--panel3, var(--panel2)); color:var(--text); }
|
||||
.widgetBtnForm{ margin:0; }
|
||||
.widgetBtn{
|
||||
width:100%; display:flex; flex-direction:column; align-items:center; gap:4px;
|
||||
@@ -956,6 +995,41 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
white-space:nowrap; pointer-events:none; z-index:20;
|
||||
}
|
||||
|
||||
/* Bulle de l'action "indication" (voir forgeShowIndicationBubble,
|
||||
collision-rules-controller.js) — même patron que .collisionInteractBubble
|
||||
ci-dessus (sœur de l'objet, position calculée en JS), mais un texte
|
||||
LIBRE (pas juste une touche) qui peut passer à la ligne, et sa propre
|
||||
classe pour ne jamais interférer avec le cycle d'affichage de la bulle
|
||||
"Appuie sur X" (minuterie propre, pas liée à l'état "actif" d'un
|
||||
déclencheur "interagir"). */
|
||||
.collisionIndicationBubble{
|
||||
display:none; position:absolute; transform:translate(-50%, -100%); margin-top:-6px;
|
||||
background:#2a3a5a; color:#fff; border-radius:10px; padding:6px 12px; font-size:13px;
|
||||
max-width:220px; text-align:center; white-space:normal; pointer-events:none; z-index:20;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
/* Overlay de l'action "video" (voir forgeShowVideoOverlay,
|
||||
static/js/play/video-overlay.js) — posé une seule fois sur <body>,
|
||||
réutilisé pour chaque lecture. "plein_ecran" couvre toute la fenêtre de
|
||||
jeu ; "incrustation" reste une petite fenêtre en bas à droite, la scène
|
||||
restant visible autour. */
|
||||
.forgeVideoOverlay{
|
||||
display:none; position:fixed; z-index:1000; align-items:center; justify-content:center;
|
||||
}
|
||||
.forgeVideoOverlay-pleinEcran{
|
||||
inset:0; background:rgba(0,0,0,.92);
|
||||
}
|
||||
.forgeVideoOverlay-incrustation{
|
||||
right:24px; bottom:24px; left:auto; top:auto; width:min(40vw, 480px);
|
||||
}
|
||||
.forgeVideoOverlay .forgeVideoOverlayPlayer{ max-width:100%; max-height:80vh; display:block; }
|
||||
.forgeVideoOverlay-incrustation .forgeVideoOverlayPlayer{ width:100%; max-height:none; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,.5); }
|
||||
.forgeVideoOverlay .forgeVideoOverlaySkip{
|
||||
position:absolute; top:12px; right:12px; background:rgba(0,0,0,.6); color:#fff; border:1px solid rgba(255,255,255,.4);
|
||||
border-radius:6px; padding:6px 12px; font-size:13px; cursor:pointer;
|
||||
}
|
||||
|
||||
/* ---- Reliquat de l'ancien onglet "🗺️ Quêtes" (retiré, voir onglet
|
||||
"Déclencheurs" et static/js/triggers/trigger-editor.js) — règles
|
||||
orphelines gardées telles quelles, aucune ne s'applique plus. ---- */
|
||||
@@ -1007,6 +1081,30 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
boutons Réplique/Question/Action TOUJOURS tout en bas — ils ajoutent
|
||||
dans le dialogue du déclencheur ACTIF (voir .questTriggerCol.active),
|
||||
jamais dans une liste séparée. */
|
||||
/* Onglet "Déclencheurs" — menu latéral gauche (demande explicite) pour
|
||||
choisir entre les déclencheurs D'OBJET ("🧩 Objets") et D'ÉCRAN ("🖥️
|
||||
Écran", voir screens/rendering/screen_triggers.py), plutôt que les
|
||||
empiler l'un sous l'autre — même patron visuel que .iaCol-left/
|
||||
.iaConversationItem (chat IA) pour rester cohérent avec le reste de
|
||||
l'éditeur.*/
|
||||
.triggersTabLayout{
|
||||
display:flex; width:100%; flex:1 1 auto; min-height:0;
|
||||
border:1px solid var(--border); border-radius:10px; overflow:hidden;
|
||||
}
|
||||
.triggersTabSidebar{
|
||||
flex:0 0 190px; border-right:1px solid var(--border); background:var(--panel2);
|
||||
display:flex; flex-direction:column; gap:4px; padding:10px;
|
||||
}
|
||||
.triggersTabSidebarItem{
|
||||
display:flex; align-items:center; gap:8px; width:100%; padding:10px 12px;
|
||||
border-radius:8px; border:1px solid transparent; background:transparent;
|
||||
color:var(--text-dim); text-align:left; cursor:pointer; font-size:13.5px; font-weight:600;
|
||||
}
|
||||
.triggersTabSidebarItem:hover{ background:var(--panel); }
|
||||
.triggersTabSidebarItem.active{ background:var(--panel); border-color:var(--accent); color:var(--text); }
|
||||
.triggersTabContent{ flex:1 1 auto; min-width:0; min-height:0; display:flex; flex-direction:column; }
|
||||
.triggersTabPane{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; padding:16px; }
|
||||
.triggersTabPaneIntro{ margin-bottom:10px; }
|
||||
.questDialogueColumns{ flex:1; min-height:0; display:flex; }
|
||||
.questDialogueColumn{
|
||||
width:90%; margin:0 auto; height:100%; min-height:0; display:flex; flex-direction:column; gap:8px;
|
||||
@@ -1068,11 +1166,28 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
le losange doit être plus grande"). */
|
||||
.questTriggerConnector.tall{ height:32px; width:3px; }
|
||||
.questTriggerConnector.tall::after{ border-width:7px; }
|
||||
.questTriggerPill{ width:100%; border-radius:100px; padding:8px 14px; font-size:12px; font-weight:700; text-align:center; }
|
||||
.questTriggerPill{ width:100%; border-radius:100px; padding:8px 14px; font-size:12px; font-weight:700; text-align:center; position:relative; }
|
||||
.questTriggerPill.trigger{ background:#f2994a; color:#1a1002; }
|
||||
.questTriggerPill.interagir{ background:#d4a72c; color:#1a1002; }
|
||||
.questTriggerPill.quete{ background:#9b59b6; color:#fff; }
|
||||
.questTriggerPill.none{ background:transparent; border:1.5px dashed var(--border); color:var(--muted, #8b95a7); }
|
||||
/* Contrôles ↑/↓/✕ par bloc étiquette+action (demande explicite : "que
|
||||
chaque bloc étiquette + action soit déplaçable ou supprimable par
|
||||
l'utilisateur et par l'IA aussi") — collés au bord droit de la
|
||||
pastille, discrets tant que la souris n'est pas dessus. */
|
||||
.questTriggerPillControls{
|
||||
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||||
display:flex; gap:2px; opacity:.55;
|
||||
}
|
||||
.questTriggerPill:hover .questTriggerPillControls{ opacity:1; }
|
||||
.questTriggerPillCtrlBtn{
|
||||
width:20px; height:20px; border-radius:6px; border:none; background:rgba(0,0,0,.25);
|
||||
color:inherit; font-size:11px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.questTriggerPillCtrlBtn:hover{ background:rgba(0,0,0,.45); }
|
||||
.questTriggerPillCtrlBtn:disabled{ opacity:.3; cursor:default; }
|
||||
.questTriggerPillCtrlBtn:disabled:hover{ background:rgba(0,0,0,.25); }
|
||||
.questTriggerPillCtrlBtn-delete:hover{ background:#e0524d; color:#fff; }
|
||||
|
||||
/* Rectangle "dialogue" — même largeur que le reste de la chaîne (100% de
|
||||
son conteneur, comme les pastilles, maquette validée : "le rectangle
|
||||
@@ -1307,6 +1422,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
flex-shrink:0; background:none; border:none; color:rgba(255,255,255,.75); cursor:pointer;
|
||||
font-size:11px; padding:0; transition:color .15s ease;
|
||||
}
|
||||
/* Voix off d'une réplique (demande explicite : "ajouter une réplique
|
||||
audio") — un sélecteur discret sous le texte, plus un aperçu <audio>
|
||||
natif SEULEMENT quand une voix off est choisie (voir
|
||||
triggerBubbleAudioControlHtml, static/js/triggers/trigger-editor.js). */
|
||||
.questBubbleAudioRow{ margin-top:6px; display:flex; flex-direction:column; gap:4px; }
|
||||
.questBubbleAudioSelect{
|
||||
font-size:11px; background:rgba(0,0,0,.2); color:#fff; border:1px solid rgba(255,255,255,.25);
|
||||
border-radius:4px; padding:2px 4px; max-width:100%;
|
||||
}
|
||||
.questBubbleAudioSelect option{ color:#111; }
|
||||
.questBubbleAudioPreview{ width:100%; height:28px; }
|
||||
.questBubbleDelete:hover{ color:#fff; }
|
||||
|
||||
.listRow.selected{ border-color:var(--accent); background:var(--panel2); }
|
||||
|
||||
@@ -181,7 +181,9 @@
|
||||
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/dialogue-box-controller.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/video-overlay.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/screen-triggers-controller.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
|
||||
<script>
|
||||
|
||||
+137
-14
@@ -75,20 +75,117 @@
|
||||
<details class="propGroup" open>
|
||||
<summary>Mes assets</summary>
|
||||
<div class="propGroupBody">
|
||||
<p class="help">Réutilisable dans tous tes projets — jamais visible par les autres comptes. Un son ne doit pas dépasser 3 minutes.</p>
|
||||
|
||||
<!-- Deux boutons d'import D'IMAGE distincts (demande explicite :
|
||||
"un bouton d'import séparé pour les images de fond et les
|
||||
objets") — le fond et le décor se posent différemment (voir
|
||||
screens/scenes/add_scene_object.py : un fond démarre au coin
|
||||
haut-gauche, s'empile en dessous de tout, peut devenir plus
|
||||
grand que la caméra), il faut donc savoir lequel des deux le
|
||||
créateur veut AVANT de poser l'objet, pas seulement au clic
|
||||
sur la tuile. Le choix est mémorisé (scene_kind, voir
|
||||
auth/create_user_asset.py) pour que TOUTE tuile de "Mes
|
||||
assets" pose ensuite automatiquement le bon type. -->
|
||||
<details class="propGroup" open>
|
||||
<summary>Fonds</summary>
|
||||
<div class="propGroupBody">
|
||||
<label class="button is-small is-fullwidth mb-2">
|
||||
+ Importer une image de fond
|
||||
<input type="file" accept="image/*" style="display:none" onchange="uploadUserAsset(this, 'fond')">
|
||||
</label>
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for asset in user_assets %}
|
||||
{% for asset in user_assets_fond %}
|
||||
<div class="userAssetTileWrap">
|
||||
<button type="button" class="iconGalleryTile" title="{{ asset.original_name or 'Asset' }}"
|
||||
onclick="addSceneObject('decor', null, null, {{ asset.id }})">
|
||||
<img src="{{ url_for('serve_user_asset', user_id=asset.user_id, filename=asset.filename) }}"
|
||||
alt="{{ asset.original_name or 'Asset' }}" class="personnageThumb">
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'fond', null, null, {{ asset.id }})"
|
||||
onclick="addSceneObject('fond', null, null, {{ asset.id }})">
|
||||
<img src="{{ asset.url }}" alt="{{ asset.original_name or 'Asset' }}" class="personnageThumb">
|
||||
</button>
|
||||
<div class="userAssetRowFooter">
|
||||
<button type="button" class="userAssetReclassifyBtn" title="Utiliser comme décor/objet"
|
||||
onclick="reclassifyUserAsset({{ asset.id }}, 'decor')">→ Décor</button>
|
||||
<button type="button" class="userAssetDeleteBtn" title="Supprimer"
|
||||
onclick="deleteUserAsset({{ asset.id }}, '{{ (asset.original_name or asset.filename)|e }}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label class="button is-small is-fullwidth mt-2">
|
||||
+ Importer une image
|
||||
<input type="file" accept="image/*" style="display:none" onchange="uploadUserAsset(this)">
|
||||
{% if not user_assets_fond %}<p class="help">Aucune image de fond importée pour l'instant.</p>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="propGroup" open>
|
||||
<summary>Décors/objets</summary>
|
||||
<div class="propGroupBody">
|
||||
<label class="button is-small is-fullwidth mb-2">
|
||||
+ Importer un décor/objet
|
||||
<input type="file" accept="image/*" style="display:none" onchange="uploadUserAsset(this, 'decor')">
|
||||
</label>
|
||||
<p class="help">Réutilisable dans tous tes projets — jamais visible par les autres comptes.</p>
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for asset in user_assets_decor %}
|
||||
<div class="userAssetTileWrap">
|
||||
<button type="button" class="iconGalleryTile" title="{{ asset.original_name or 'Asset' }}"
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'decor', null, null, {{ asset.id }})"
|
||||
onclick="addSceneObject('decor', null, null, {{ asset.id }})">
|
||||
<img src="{{ asset.url }}" alt="{{ asset.original_name or 'Asset' }}" class="personnageThumb">
|
||||
</button>
|
||||
<div class="userAssetRowFooter">
|
||||
<button type="button" class="userAssetReclassifyBtn" title="Utiliser comme fond"
|
||||
onclick="reclassifyUserAsset({{ asset.id }}, 'fond')">→ Fond</button>
|
||||
<button type="button" class="userAssetDeleteBtn" title="Supprimer"
|
||||
onclick="deleteUserAsset({{ asset.id }}, '{{ (asset.original_name or asset.filename)|e }}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not user_assets_decor %}<p class="help">Aucun décor/objet importé pour l'instant.</p>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="propGroup">
|
||||
<summary>Sons</summary>
|
||||
<div class="propGroupBody">
|
||||
<label class="button is-small is-fullwidth mb-2">
|
||||
+ Importer un son
|
||||
<input type="file" accept="audio/*" style="display:none" onchange="uploadUserAsset(this)">
|
||||
</label>
|
||||
{% for asset in user_assets_audio %}
|
||||
<div class="userAssetRow">
|
||||
<audio controls preload="none" src="{{ asset.url }}"></audio>
|
||||
<div class="userAssetRowFooter">
|
||||
<span class="userAssetName" title="{{ asset.original_name or asset.filename }}">{{ asset.original_name or asset.filename }}</span>
|
||||
<button type="button" class="userAssetDeleteBtn" title="Supprimer"
|
||||
onclick="deleteUserAsset({{ asset.id }}, '{{ (asset.original_name or asset.filename)|e }}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not user_assets_audio %}<p class="help">Aucun son importé pour l'instant.</p>{% endif %}
|
||||
<p class="help">Utilisable dans les actions "Jouer un son" des déclencheurs.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="propGroup">
|
||||
<summary>Vidéos</summary>
|
||||
<div class="propGroupBody">
|
||||
<label class="button is-small is-fullwidth mb-2">
|
||||
+ Importer une vidéo (MP4, 5 min max)
|
||||
<input type="file" accept="video/mp4,.mp4" style="display:none" onchange="uploadUserAsset(this)">
|
||||
</label>
|
||||
{% for asset in user_assets_video %}
|
||||
<div class="userAssetRow">
|
||||
<video controls preload="none" src="{{ asset.url }}" style="max-height:120px;"></video>
|
||||
<div class="userAssetRowFooter">
|
||||
<span class="userAssetName" title="{{ asset.original_name or asset.filename }}">{{ asset.original_name or asset.filename }}</span>
|
||||
<button type="button" class="userAssetDeleteBtn" title="Supprimer"
|
||||
onclick="deleteUserAsset({{ asset.id }}, '{{ (asset.original_name or asset.filename)|e }}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not user_assets_video %}<p class="help">Aucune vidéo importée pour l'instant.</p>{% endif %}
|
||||
<p class="help">Utilisable dans les actions "Jouer une vidéo" des déclencheurs.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<!-- Galerie de personnages Forge (VRAIES miniatures) — cliquer une
|
||||
@@ -100,7 +197,9 @@
|
||||
<div class="propGroupBody">
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for slug_c, character in sprite_library_families %}
|
||||
<button type="button" class="iconGalleryTile" title="{{ character.label }}" onclick="addSceneObject('personnage', '{{ slug_c }}')">
|
||||
<button type="button" class="iconGalleryTile" title="{{ character.label }}"
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'personnage', '{{ slug_c }}', null, null)"
|
||||
onclick="addSceneObject('personnage', '{{ slug_c }}')">
|
||||
<img src="{{ character.animations.idle[0] }}" alt="{{ character.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -119,7 +218,9 @@
|
||||
<div class="propGroupBody">
|
||||
<div class="iconGallery personnageGallery">
|
||||
{% for slug_b, background in background_library.items() %}
|
||||
<button type="button" class="iconGalleryTile" title="{{ background.label }}" onclick="addSceneObject('fond', null, '{{ slug_b }}')">
|
||||
<button type="button" class="iconGalleryTile" title="{{ background.label }}"
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'fond', null, '{{ slug_b }}', null)"
|
||||
onclick="addSceneObject('fond', null, '{{ slug_b }}')">
|
||||
<img src="{{ background.url }}" alt="{{ background.label }}" class="personnageThumb">
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -138,9 +239,15 @@
|
||||
<details class="propGroup">
|
||||
<summary>Interface</summary>
|
||||
<div class="propGroupBody">
|
||||
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('dialogue_box')">Boîte de dialogue</button>
|
||||
<button type="button" class="button is-small is-fullwidth mb-2" onclick="addSceneObject('quiz_box')">Boîte à quiz</button>
|
||||
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('score_widget')">Score</button>
|
||||
<button type="button" class="button is-small is-fullwidth mb-2"
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'dialogue_box', null, null, null)"
|
||||
onclick="addSceneObject('dialogue_box')">Boîte de dialogue</button>
|
||||
<button type="button" class="button is-small is-fullwidth mb-2"
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'quiz_box', null, null, null)"
|
||||
onclick="addSceneObject('quiz_box')">Boîte à quiz</button>
|
||||
<button type="button" class="button is-small is-fullwidth"
|
||||
draggable="true" ondragstart="onGalleryTileDragStart(event, 'score_widget', null, null, null)"
|
||||
onclick="addSceneObject('score_widget')">Score</button>
|
||||
<p class="help">Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "Déclencheurs") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.</p>
|
||||
</div>
|
||||
</details>
|
||||
@@ -167,7 +274,8 @@
|
||||
|
||||
<div class="builderTabPanel active" data-tab-panel="screen">
|
||||
<div class="canvasFrame">
|
||||
<div class="canvas" id="canvas" style="width:{{ world_width }}px; height:{{ world_height }}px; aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;">
|
||||
<div class="canvas" id="canvas" style="width:{{ world_width }}px; height:{{ world_height }}px; aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;"
|
||||
ondragover="onCanvasDragOver(event)" ondrop="onCanvasDrop(event)">
|
||||
{% if world_width > screen.scene_width or world_height > screen.scene_height %}
|
||||
<!-- "🎥 Champ de la caméra" : le monde (ce canevas, voir
|
||||
world_width/world_height ci-dessus, routes/scenes/
|
||||
@@ -647,6 +755,21 @@
|
||||
// noms des variables globales du jeu (voir l'onglet "Variables"
|
||||
// ci-dessus), pour peupler son sélecteur de variable.
|
||||
var GLOBAL_VARIABLE_NAMES = {{ global_variables | map(attribute='name') | list | tojson }};
|
||||
// Nouvelles actions "surbrillance"/"vidéo" (voir static/js/scenes/
|
||||
// collision-rules-editor.js) — même patron que les autres LABELS_MAP
|
||||
// ci-dessus.
|
||||
var SURBRILLANCE_LABELS_MAP = {{ surbrillance_labels_json|safe }};
|
||||
var VIDEO_MODE_LABELS_MAP = {{ video_mode_labels_json|safe }};
|
||||
// "Mes assets" (voir auth/user_assets_dir.py) — url déjà calculée côté
|
||||
// serveur (url_for), pour peupler le sélecteur "asset_url" de "son"/
|
||||
// "vidéo" sans redemander une URL au serveur à chaque ouverture de
|
||||
// l'assistant.
|
||||
var USER_ASSETS_OPTIONS = {{ user_assets_options_json|safe }};
|
||||
// Section "Déclencheurs de l'écran" (voir static/js/triggers/
|
||||
// trigger-editor.js::initScreenTriggersSection) — id de l'écran ouvert,
|
||||
// nécessaire pour poster sur /game/<slug>/screens/<id>/triggers.
|
||||
var CURRENT_SCREEN_ID = {{ screen.id }};
|
||||
var SCREEN_TRIGGERS_INITIAL = {{ screen_triggers|tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||
|
||||
@@ -148,6 +148,40 @@ def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(
|
||||
assert "[déclencheur déjà configuré]" in description
|
||||
|
||||
|
||||
def test_describe_scene_state_reports_no_screen_trigger_by_default(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert "Aucun déclencheur d'écran" in description
|
||||
|
||||
|
||||
def test_describe_scene_state_reports_an_existing_screen_trigger(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue"}},
|
||||
]))
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert "1 déclencheur(s) D'ÉCRAN" in description
|
||||
|
||||
|
||||
def test_system_prompt_documents_the_new_triggers_and_actions():
|
||||
"""Bug à éviter : ajouter un type de déclencheur/action côté moteur
|
||||
(screens/rendering/collision_rules.py) sans jamais le documenter dans
|
||||
le system prompt le rendrait invisible pour Ruby malgré l'outil qui
|
||||
l'accepte techniquement."""
|
||||
prompt = ai.chat._SYSTEM_PROMPT
|
||||
for keyword in ("\"clic\"", "\"survol\"", "\"affichage\"", "surbrillance", "visibilite", "\"son\"", "\"video\"", "indication"):
|
||||
assert keyword in prompt, keyword
|
||||
|
||||
|
||||
def test_system_prompt_asks_ruby_to_clarify_ambiguous_trigger_choice():
|
||||
assert "pose la question" in ai.chat._SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action():
|
||||
assert "interagir" in ai.chat._SYSTEM_PROMPT
|
||||
assert "APPUYER SUR UNE TOUCHE" in ai.chat._SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
|
||||
@@ -200,6 +200,92 @@ def test_dispatch_append_action_to_trigger_extends_an_existing_chain(client, tmp
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score_ia"
|
||||
|
||||
|
||||
def test_dispatch_set_collision_rules_accepts_clic_and_survol_triggers(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
rules = [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": object_id, "valeur": "toggle"}}]
|
||||
dispatch_tool(slug, screen_id, 1, "set_collision_rules", {"object_id": object_id, "rules": rules})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj) == rules
|
||||
|
||||
|
||||
def test_dispatch_set_screen_triggers_sanitizes_and_stores(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
rules = [{"trigger": "affichage", "action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"}}]
|
||||
result = dispatch_tool(slug, screen_id, 1, "set_screen_triggers", {"rules": rules})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == rules
|
||||
|
||||
|
||||
def test_dispatch_append_action_to_screen_trigger_extends_an_existing_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "append_action_to_screen_trigger", {
|
||||
"after_id": "d_1",
|
||||
"action": {"type": "variable", "target_variable": "score_ia", "data_operation": "incrementer", "data_value": "1"},
|
||||
})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen)[0]["action"]["then"]["target_variable"] == "score_ia"
|
||||
|
||||
|
||||
def test_dispatch_remove_trigger_action_removes_a_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "remove_trigger_action", {"object_id": object_id, "leaf_id": "d_1"})
|
||||
assert result["ok"] is True
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_dispatch_move_trigger_action_swaps_a_leaf_up(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "move_trigger_action", {"object_id": object_id, "leaf_id": "d_1", "direction": "up"})
|
||||
assert result["ok"] is True
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_dispatch_remove_screen_trigger_action_removes_a_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "remove_screen_trigger_action", {"leaf_id": "d_1"})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_dispatch_move_screen_trigger_action_swaps_a_leaf_up(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([{
|
||||
"trigger": "affichage",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "move_screen_trigger_action", {"leaf_id": "d_1", "direction": "up"})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen)[0]["action"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_dispatch_tool_raises_for_an_unknown_tool_name(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
try:
|
||||
@@ -269,6 +355,24 @@ def test_set_collision_rules_action_type_enum_matches_the_source_constant():
|
||||
assert action_schema["properties"]["type"]["enum"] == list(ACTION_TYPES)
|
||||
|
||||
|
||||
def test_set_collision_rules_trigger_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import TRIGGER_TYPES
|
||||
rules_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]
|
||||
assert rules_schema["properties"]["trigger"]["enum"] == list(TRIGGER_TYPES)
|
||||
|
||||
|
||||
def test_set_screen_triggers_trigger_enum_matches_the_source_constant():
|
||||
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
|
||||
rules_schema = _tool("set_screen_triggers")["input_schema"]["properties"]["rules"]["items"]
|
||||
assert rules_schema["properties"]["trigger"]["enum"] == list(TRIGGER_TYPES_SCREEN)
|
||||
|
||||
|
||||
def test_set_collision_rules_video_mode_enum_matches_the_source_constant():
|
||||
from screens.labels.video_modes import VIDEO_MODE_LABELS
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
assert set(action_schema["properties"]["mode"]["enum"]) == set(VIDEO_MODE_LABELS)
|
||||
|
||||
|
||||
def test_set_collision_rules_condition_operator_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import CONDITION_OPERATOR_KEYS
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
|
||||
@@ -257,10 +257,14 @@ def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves():
|
||||
def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
|
||||
"""_MAX_THEN_CHAIN borne la chaîne — une chaîne trop longue postée à la
|
||||
main est coupée plutôt que rejetée entièrement (même tolérance que le
|
||||
reste de ce moteur)."""
|
||||
reste de ce moteur). Bug corrigé : à 4 (l'ancienne valeur), un
|
||||
créateur enchaînant "attendre -> dialogue -> surbrillance -> attendre"
|
||||
(4 maillons, un scénario tout à fait normal) voyait déjà TOUT
|
||||
5e maillon ajouté ensuite tronqué en silence — voir
|
||||
screens/rendering/collision_rules.py::_MAX_THEN_CHAIN."""
|
||||
chain = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
node = chain
|
||||
for _ in range(10): # largement au-delà de _MAX_THEN_CHAIN (4)
|
||||
for _ in range(50): # largement au-delà de _MAX_THEN_CHAIN (30)
|
||||
node["then"] = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
node = node["then"]
|
||||
rules = screens.sanitize_collision_rules([{"trigger": "collision", "action": chain}])
|
||||
@@ -269,7 +273,36 @@ def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
|
||||
while node:
|
||||
depth += 1
|
||||
node = node.get("then")
|
||||
assert depth == 4 # _MAX_THEN_CHAIN, jamais plus
|
||||
assert depth == 30 # _MAX_THEN_CHAIN, jamais plus
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_then_chain_of_five_leaves_the_bug_that_broke_the_user():
|
||||
"""Reproduction directe du bug signalé : attendre -> dialogue ->
|
||||
surbrillance -> attendre -> surbrillance (5 maillons) doit survivre
|
||||
intact à la resanitisation qui suit chaque écriture (voir
|
||||
append_action_to_screen_trigger/set_then_action_in_rules)."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {
|
||||
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
||||
"then": {
|
||||
"type": "surbrillance", "id": "s_1", "object_id": 37, "valeur": "on",
|
||||
"then": {
|
||||
"type": "attendre", "id": "a_2", "data_value": "3",
|
||||
"then": {"type": "surbrillance", "id": "s_2", "object_id": 37, "valeur": "off"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}])
|
||||
depth = 0
|
||||
node = rules[0]["action"]
|
||||
while node:
|
||||
depth += 1
|
||||
node = node.get("then")
|
||||
assert depth == 5
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_attaches_a_new_leaf_at_the_end_of_a_dialogue_chain():
|
||||
@@ -300,7 +333,11 @@ def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id():
|
||||
assert screens.set_then_action_in_rules(rules, "d_absent", {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}) is False
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_never_overwrites_an_existing_then():
|
||||
def test_set_then_action_in_rules_inserts_between_a_leaf_and_its_existing_then():
|
||||
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
|
||||
n'importe quel niveau du schéma" — la suite déjà posée (ici "score")
|
||||
passe DERRIÈRE la nouvelle action insérée ("vies"), jamais écrasée
|
||||
(comportement précédent : refuser purement et simplement)."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
@@ -309,8 +346,321 @@ def test_set_then_action_in_rules_never_overwrites_an_existing_then():
|
||||
},
|
||||
}])
|
||||
attached = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"})
|
||||
assert attached is False
|
||||
assert rules[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
||||
assert attached is True
|
||||
action = rules[0]["action"]
|
||||
assert action["then"]["target_variable"] == "vies"
|
||||
assert action["then"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_accepts_clic_and_survol_triggers():
|
||||
""""clic"/"survol" — nouveaux déclencheurs sans besoin d'un joueur,
|
||||
voir screens/rendering/collision_rules.py."""
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "clic", "action": {"type": "dialogue"}},
|
||||
{"trigger": "survol", "action": {"type": "dialogue"}},
|
||||
])
|
||||
assert [r["trigger"] for r in rules] == ["clic", "survol"]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_surbrillance_action():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "clic",
|
||||
"action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"},
|
||||
}])
|
||||
assert rules == [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"}}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_a_surbrillance_action_with_an_unknown_valeur_or_object_id():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "sorcellerie"}},
|
||||
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": "pas-un-id", "valeur": "on"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_visibilite_action():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "visibilite", "object_id": 3, "valeur": "masque"},
|
||||
}])
|
||||
assert rules == [{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "masque"}}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_a_visibilite_action_with_an_unknown_valeur():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "sorcellerie"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_son_action():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "clic",
|
||||
"action": {"type": "son", "asset_url": "/assets/1/bip.mp3"},
|
||||
}])
|
||||
assert rules == [{"trigger": "clic", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_a_son_action_without_asset_url():
|
||||
rules = screens.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_video_action():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "survol",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "survol",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
||||
}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_a_video_action_with_an_unknown_mode():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "survol",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "sorcellerie"},
|
||||
}])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_an_indication_action_with_and_without_duree_ms():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
|
||||
}]
|
||||
rules_no_duree = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !"},
|
||||
}])
|
||||
assert "duree_ms" not in rules_no_duree[0]["action"]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_an_indication_action_without_texte():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "indication", "object_id": 5, "texte": " "},
|
||||
}])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_chains_a_video_then_a_dialogue():
|
||||
"""Une "video" reste une FEUILLE chaînable, comme dialogue/variable
|
||||
(demande explicite : pouvoir enchaîner plusieurs actions)."""
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "video", "id": "v_1", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_an_attendre_action():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "attendre", "data_value": "2.5"},
|
||||
}])
|
||||
assert rules == [{"trigger": "collision", "action": {"type": "attendre", "data_value": "2.5"}}]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_an_attendre_action_with_an_invalid_or_negative_value():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "attendre", "data_value": "pas-un-nombre"}},
|
||||
{"trigger": "collision", "action": {"type": "attendre", "data_value": "-1"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_chains_an_attendre_then_a_dialogue():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
||||
|
||||
|
||||
def test_set_then_action_in_rules_addresses_a_surbrillance_leaf_by_its_own_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "clic",
|
||||
"action": {"type": "surbrillance", "id": "s_1", "object_id": 7, "valeur": "on"},
|
||||
}])
|
||||
attached = screens.set_then_action_in_rules(rules, "s_1", {"type": "son", "asset_url": "/assets/1/bip.mp3"})
|
||||
assert attached is True
|
||||
assert rules[0]["action"]["then"] == {"type": "son", "asset_url": "/assets/1/bip.mp3"}
|
||||
|
||||
|
||||
def test_remove_action_from_rules_removes_the_whole_rule_when_removing_a_single_head_leaf():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
removed = screens.remove_action_from_rules(rules, "d_1")
|
||||
assert removed is True
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_remove_action_from_rules_promotes_the_then_when_removing_the_head_of_a_chain():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
removed = screens.remove_action_from_rules(rules, "a_1")
|
||||
assert removed is True
|
||||
assert rules == [{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}]
|
||||
|
||||
|
||||
def test_remove_action_from_rules_removes_a_middle_leaf_and_reconnects_the_chain():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
},
|
||||
}])
|
||||
removed = screens.remove_action_from_rules(rules, "a_1")
|
||||
assert removed is True
|
||||
action = rules[0]["action"]
|
||||
assert action["type"] == "variable"
|
||||
assert action["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
||||
|
||||
|
||||
def test_remove_action_from_rules_returns_false_for_an_unknown_leaf_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
assert screens.remove_action_from_rules(rules, "d_absent") is False
|
||||
assert len(rules) == 1
|
||||
|
||||
|
||||
def test_remove_action_from_rules_removes_a_leaf_inside_a_condition_branch():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
removed = screens.remove_action_from_rules(rules, "d_vrai")
|
||||
assert removed is True
|
||||
assert rules[0]["action"]["si_vrai"] is None
|
||||
|
||||
|
||||
def test_remove_action_from_rules_removes_interagir_entirely_when_its_only_sub_action_is_removed():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
}])
|
||||
removed = screens.remove_action_from_rules(rules, "d_1")
|
||||
assert removed is True
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_move_action_in_rules_swaps_adjacent_leaves_up_and_down():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
},
|
||||
}])
|
||||
moved = screens.move_action_in_rules(rules, "a_1", "up")
|
||||
assert moved is True
|
||||
action = rules[0]["action"]
|
||||
assert action["type"] == "attendre"
|
||||
assert action["then"]["type"] == "variable"
|
||||
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
||||
|
||||
|
||||
def test_move_action_in_rules_at_the_start_of_the_chain_moving_up_has_no_effect_but_still_found():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
moved = screens.move_action_in_rules(rules, "v_1", "up")
|
||||
assert moved is True
|
||||
assert rules[0]["action"]["target_variable"] == "score" # inchangé, déjà en tête
|
||||
|
||||
|
||||
def test_move_action_in_rules_returns_false_for_an_unknown_leaf_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
assert screens.move_action_in_rules(rules, "d_absent", "up") is False
|
||||
|
||||
|
||||
def test_backfill_missing_leaf_ids_assigns_a_stable_id_to_a_variable_leaf_without_one():
|
||||
"""Bug corrigé : une action "variable" posée sans "id" (ex. par l'IA,
|
||||
ce champ restant optionnel dans son schéma, ou une session
|
||||
antérieure à cette convention) restait à jamais impossible à
|
||||
adresser — "+ Ajouter une action" n'apparaissait jamais après elle."""
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}]
|
||||
changed = screens.backfill_missing_leaf_ids(rules)
|
||||
assert changed is True
|
||||
assert rules[0]["action"]["id"]
|
||||
|
||||
|
||||
def test_backfill_missing_leaf_ids_reaches_inside_condition_branches_and_then_chains():
|
||||
rules = [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "x", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {
|
||||
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
|
||||
"then": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
},
|
||||
"si_faux": None,
|
||||
},
|
||||
}]
|
||||
changed = screens.backfill_missing_leaf_ids(rules)
|
||||
assert changed is True
|
||||
assert rules[0]["action"]["si_vrai"]["then"]["id"]
|
||||
|
||||
|
||||
def test_backfill_missing_leaf_ids_is_a_noop_when_every_leaf_already_has_one():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
changed = screens.backfill_missing_leaf_ids(rules)
|
||||
assert changed is False
|
||||
assert rules[0]["action"]["id"] == "v_1"
|
||||
|
||||
|
||||
def test_backfill_missing_leaf_ids_never_touches_a_dialogue_leaf_which_already_has_its_own_id():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
changed = screens.backfill_missing_leaf_ids(rules)
|
||||
assert changed is False
|
||||
assert "id" not in rules[0]["action"]
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_drops_malformed_or_unknown_rules():
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Lignes de dialogue (voir db/dialogue_lines.py) — réplique audio
|
||||
(demande explicite : "ajouter une réplique audio"), voix off jouée en
|
||||
même temps que la bulle de réplique s'affiche (voir static/js/play/
|
||||
dialogue-box-controller.js::forgeRenderDialogueBoxLine)."""
|
||||
import db
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_keeps_a_valid_audio_url_on_a_dialogue_line():
|
||||
lines = db.sanitize_dialogue_lines([
|
||||
{"speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"},
|
||||
])
|
||||
assert lines == [{"type": "dialogue", "speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"}]
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_omits_audio_url_when_absent():
|
||||
lines = db.sanitize_dialogue_lines([{"speaker": "Marc", "text": "Bonjour !"}])
|
||||
assert "audio_url" not in lines[0]
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_omits_a_blank_audio_url():
|
||||
lines = db.sanitize_dialogue_lines([{"speaker": "Marc", "text": "Bonjour !", "audio_url": " "}])
|
||||
assert "audio_url" not in lines[0]
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_ignores_audio_url_on_a_question_line():
|
||||
"""L'audio n'a de sens que pour une RÉPLIQUE — une question à choix
|
||||
("❓ Question") reste inchangée, voir QUESTION_REWARD_TYPES."""
|
||||
lines = db.sanitize_dialogue_lines([{
|
||||
"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1,
|
||||
"audio_url": "/assets/1/question.mp3",
|
||||
}])
|
||||
assert "audio_url" not in lines[0]
|
||||
@@ -23,6 +23,62 @@ def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
|
||||
assert "Personnages" in html
|
||||
|
||||
|
||||
def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsections(client, tmp_game_slug_cleanup):
|
||||
"""Demande explicite : "des sous-menu pour voir, utiliser ou
|
||||
supprimer des son, image et video", PUIS "un bouton d'import séparé
|
||||
pour les images de fond et les objets" — "Mes assets" doit séparer
|
||||
Fonds/Décors/Sons/Vidéos (voir auth/user_asset_kind.py,
|
||||
auth/create_user_asset.py::scene_kind), chacun avec son bouton de
|
||||
suppression ; le sélecteur de fichier des actions "son"/"vidéo"
|
||||
(USER_ASSETS_OPTIONS), lui, voit TOUJOURS tout, peu importe le type."""
|
||||
import auth
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
fond_id = auth.create_user_asset(user_id, "ciel.png", original_name="ciel.png", source="upload", scene_kind="fond")
|
||||
decor_id = auth.create_user_asset(user_id, "photo.png", original_name="photo.png", source="upload", scene_kind="decor")
|
||||
audio_id = auth.create_user_asset(user_id, "bip.mp3", original_name="bip.mp3", source="upload")
|
||||
video_id = auth.create_user_asset(user_id, "clip.mp4", original_name="clip.mp4", source="upload")
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
m = re.search(r"var USER_ASSETS_OPTIONS = (\[.*?\]);", html)
|
||||
names = [o["name"] for o in json.loads(m.group(1))]
|
||||
assert set(names) == {"ciel.png", "photo.png", "bip.mp3", "clip.mp4"}
|
||||
|
||||
fond_section = html.split('<summary>Fonds</summary>', 1)[1].split("</details>", 1)[0]
|
||||
decor_section = html.split('<summary>Décors/objets</summary>', 1)[1].split("</details>", 1)[0]
|
||||
sons_section = html.split('<summary>Sons</summary>', 1)[1].split("</details>", 1)[0]
|
||||
videos_section = html.split('<summary>Vidéos</summary>', 1)[1].split("</details>", 1)[0]
|
||||
|
||||
assert "ciel.png" in fond_section and "photo.png" not in fond_section
|
||||
assert "addSceneObject('fond', null, null, {})".format(fond_id) in fond_section
|
||||
assert "photo.png" in decor_section and "ciel.png" not in decor_section
|
||||
assert "addSceneObject('decor', null, null, {})".format(decor_id) in decor_section
|
||||
assert "bip.mp3" in sons_section and "photo.png" not in sons_section
|
||||
assert "clip.mp4" in videos_section and "photo.png" not in videos_section
|
||||
|
||||
# Un bouton de suppression pour chacun (demande explicite).
|
||||
for asset_id in (fond_id, decor_id, audio_id, video_id):
|
||||
assert "deleteUserAsset({},".format(asset_id) in html
|
||||
|
||||
|
||||
def test_scene_edit_view_defaults_a_legacy_image_without_scene_kind_to_decor(client, tmp_game_slug_cleanup):
|
||||
"""Une image importée AVANT l'ajout de scene_kind (NULL en base) doit
|
||||
rester utilisable — comportement inchangé : "decor", jamais perdue
|
||||
dans un vide entre les deux sous-sections."""
|
||||
import auth
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
auth.create_user_asset(user_id, "ancienne.png", original_name="ancienne.png", source="upload")
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
decor_section = html.split('<summary>Décors/objets</summary>', 1)[1].split("</details>", 1)[0]
|
||||
fond_section = html.split('<summary>Fonds</summary>', 1)[1].split("</details>", 1)[0]
|
||||
assert "ancienne.png" in decor_section
|
||||
assert "ancienne.png" not in fond_section
|
||||
|
||||
|
||||
def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(
|
||||
|
||||
@@ -4,9 +4,18 @@ JSON (posée quand l'appelant envoie X-Requested-With: fetch) remplace le
|
||||
rechargement complet de page — sans ça, poser un objet "vidait" un instant
|
||||
l'éditeur de collision/quêtes (tout l'état JS repartait de zéro à chaque
|
||||
ajout)."""
|
||||
import io
|
||||
import struct
|
||||
|
||||
import auth
|
||||
import screens
|
||||
|
||||
|
||||
def _png_bytes(width, height):
|
||||
ihdr = struct.pack(">II", width, height) + b"\x08\x02\x00\x00\x00"
|
||||
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IHDR" + ihdr + b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_add_ajax"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
@@ -31,6 +40,83 @@ def test_ajax_add_returns_json_instead_of_redirecting(client, tmp_game_slug_clea
|
||||
assert body["animations"]
|
||||
|
||||
|
||||
def test_ajax_add_with_drop_position_places_the_object_exactly_there(client, tmp_game_slug_cleanup):
|
||||
"""Vrai glisser-déposer (demande explicite : "n'importe où sur la
|
||||
scène") — x/y postés remplacent le défaut 100/100."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="decor", x="300", y="220")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["x"] == 300
|
||||
assert body["y"] == 220
|
||||
|
||||
|
||||
def test_ajax_add_with_drop_position_near_the_edge_is_clamped_into_the_camera(client, tmp_game_slug_cleanup):
|
||||
"""Un dépôt trop proche d'un bord ne doit jamais laisser l'objet
|
||||
déborder du cadre visible par la caméra une fois sa taille réelle
|
||||
connue (même esprit que ai/tools.py::_clamp_to_camera pour l'IA)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="decor", x="999999", y="999999")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["x"] <= 960 - body["width"]
|
||||
assert body["y"] <= 540 - body["height"]
|
||||
|
||||
|
||||
# ---------- Fond importé ("Mes assets") posé à sa taille RÉELLE — bug :
|
||||
# "je ne peux pas poser un objet hors du champ de la caméra quand
|
||||
# j'utilise un fond de scène importé" (sans ça, l'image retombait
|
||||
# toujours sur 128×128, trop petite pour agrandir le "monde" au-delà de
|
||||
# la caméra nominale 960×540) ----------
|
||||
|
||||
def test_ajax_add_fond_ignores_the_drop_position_and_stays_anchored_at_the_origin(client, tmp_game_slug_cleanup):
|
||||
"""Bug corrigé ("décalage") : le vrai glisser-déposer envoie une
|
||||
position EXACTE même pour un fond — sans ce garde-fou, un fond
|
||||
déposé ailleurs qu'au coin haut-gauche restait décalé, laissant un
|
||||
bandeau de canevas vide visible entre le bord de la scène et
|
||||
l'image."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="fond", x="450", y="300")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert (body["x"], body["y"]) == (0, 0)
|
||||
|
||||
|
||||
def test_ajax_add_fond_from_user_asset_uses_the_real_image_dimensions(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id, _filename = auth.save_user_asset(
|
||||
user_id, _png_bytes(1920, 1080), ".png", original_name="ciel.png", source="upload", scene_kind="fond",
|
||||
)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="fond", asset_id=str(asset_id))
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["width"] == 1920
|
||||
assert body["height"] == 1080
|
||||
|
||||
|
||||
# ---------- Redimensionnement manuel d'un "fond" — la route /geometry
|
||||
# doit renvoyer le "monde" recalculé pour que scene-editor.js puisse
|
||||
# rafraîchir SCENE_WIDTH/SCENE_HEIGHT tout de suite (même bug que
|
||||
# ci-dessus, mais via le redimensionnement à la souris plutôt qu'un
|
||||
# import — sans ça, l'agrandissement n'était visible qu'après un
|
||||
# rechargement complet de la page) ----------
|
||||
|
||||
def test_geometry_route_returns_the_recomputed_world_size_for_an_enlarged_fond(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="fond")
|
||||
obj_id = resp.get_json()["id"]
|
||||
geo = client.post(
|
||||
f"/game/{slug}/scene-objects/{obj_id}/geometry",
|
||||
json={"x": 0, "y": 0, "width": 2000, "height": 1200},
|
||||
)
|
||||
assert geo.status_code == 200
|
||||
body = geo.get_json()
|
||||
assert body["world_width"] >= 2000
|
||||
assert body["world_height"] >= 1200
|
||||
|
||||
|
||||
def test_non_ajax_add_still_redirects(client, tmp_game_slug_cleanup):
|
||||
"""Secours pour tout appelant non-JS (aucun connu, mais gratuit à garder) —
|
||||
comportement inchangé : redirection classique, sans le header AJAX."""
|
||||
|
||||
@@ -40,6 +40,33 @@ def test_add_decor_scene_object(tmp_game_slug_cleanup):
|
||||
assert obj["kind"] == "decor"
|
||||
|
||||
|
||||
def test_add_decor_scene_object_with_an_image_defaults_to_200x200_with_a_150x150_collision_box(tmp_game_slug_cleanup):
|
||||
"""Demande explicite : un objet/décor posé depuis "Mes assets" doit
|
||||
toujours démarrer à 200×200 (jamais la taille réelle, souvent bien
|
||||
plus grande, ni le défaut 128×128 du schéma), avec une boîte de
|
||||
collision plus petite (150×150, centrée) pour rester proche de la
|
||||
silhouette réelle."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/photo.png")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert (obj["width"], obj["height"]) == (200, 200)
|
||||
collision = screens.resolve_collision_settings(obj)
|
||||
assert (collision["width"], collision["height"]) == (150, 150)
|
||||
|
||||
|
||||
def test_add_decor_scene_object_with_an_image_ignores_real_image_dimensions(tmp_game_slug_cleanup):
|
||||
"""Contrairement à "fond" (voir add_scene_object.py), un "decor" ne
|
||||
doit JAMAIS être posé à la taille réelle de l'image importée — même
|
||||
quand elle est connue (image_width/image_height fournis)."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(
|
||||
slug, screen_id, kind="decor", image_url="/assets/1/photo.png",
|
||||
image_width=5504, image_height=3072,
|
||||
)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert (obj["width"], obj["height"]) == (200, 200)
|
||||
|
||||
|
||||
def test_scene_object_geometry_is_pixels_not_percent(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
@@ -108,6 +135,44 @@ def test_render_scene_object_fond_alt_is_always_empty_rgaa(tmp_game_slug_cleanup
|
||||
assert 'alt=""' in html
|
||||
|
||||
|
||||
def test_add_scene_object_with_explicit_position_uses_it_instead_of_the_default(tmp_game_slug_cleanup):
|
||||
"""Vrai glisser-déposer depuis la galerie (demande explicite :
|
||||
"n'importe où sur la scène") — x/y explicites remplacent le défaut
|
||||
100/100 posé par le schéma (voir ensure_scene_schema.py)."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="decor", x=250, y=340)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 250
|
||||
assert obj["y"] == 340
|
||||
|
||||
|
||||
def test_add_scene_object_without_position_still_defaults_to_100_100(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 100
|
||||
assert obj["y"] == 100
|
||||
|
||||
|
||||
def test_add_scene_object_fond_with_explicit_position_overrides_the_top_left_default(tmp_game_slug_cleanup):
|
||||
"""Un "fond" reste au coin haut-gauche (0,0) SANS position explicite
|
||||
(comportement inchangé), mais un glisser-déposer précis doit pouvoir
|
||||
le positionner ailleurs si le créateur le demande explicitement."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret", x=50, y=60)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 50
|
||||
assert obj["y"] == 60
|
||||
|
||||
|
||||
def test_add_scene_object_fond_without_position_still_defaults_to_top_left(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 0
|
||||
assert obj["y"] == 0
|
||||
|
||||
|
||||
def test_delete_scene_object_cleans_up_referencing_flow_nodes(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Déclencheurs D'ÉCRAN (voir screens/rendering/screen_triggers.py) : une
|
||||
règle "déclencheur (affichage) -> action" posée sur un ÉCRAN plutôt que
|
||||
sur un objet — SANS objet requis, pour de la narration/cinématique dès
|
||||
l'affichage. Section "Déclencheurs de l'écran" de l'éditeur de scène."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_screen_triggers"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def test_freshly_created_screen_has_no_screen_triggers(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_keeps_an_affichage_trigger():
|
||||
rules = screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue"}},
|
||||
])
|
||||
assert rules == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_rejects_triggers_not_allowed_at_screen_level():
|
||||
"""Un déclencheur d'OBJET ("collision"/"clic"/"survol") n'a pas de
|
||||
sens sans objet — seul "affichage" est autorisé pour un déclencheur
|
||||
d'écran (voir TRIGGER_TYPES_SCREEN)."""
|
||||
rules = screens.sanitize_screen_triggers([
|
||||
{"trigger": "collision", "action": {"type": "dialogue"}},
|
||||
{"trigger": "clic", "action": {"type": "dialogue"}},
|
||||
{"trigger": "survol", "action": {"type": "dialogue"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_keeps_a_video_action():
|
||||
rules = screens.sanitize_screen_triggers([{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
||||
}]
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_keeps_an_attendre_then_dialogue_chain():
|
||||
"""Cas d'usage demandé : "à l'affichage de l'écran" -> attendre N
|
||||
secondes -> déclencher un dialogue."""
|
||||
rules = screens.sanitize_screen_triggers([{
|
||||
"trigger": "affichage",
|
||||
"action": {
|
||||
"type": "attendre", "data_value": "3",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["type"] == "attendre"
|
||||
assert rules[0]["action"]["then"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_set_screen_triggers_persists_and_resolve_reads_it_back(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_triggers_are_isolated_between_screens(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id_1 = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screen_id_2 = screens.create_screen(slug, "Scène 2", kind="jeu_2d")
|
||||
screens.set_screen_triggers(slug, screen_id_1, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
screen_2 = screens.get_screen(slug, screen_id_2)
|
||||
assert screens.resolve_screen_triggers(screen_2) == []
|
||||
|
||||
|
||||
def test_append_action_to_screen_trigger_attaches_a_leaf_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
ok = screens.append_action_to_screen_trigger(
|
||||
slug, screen_id, "d_1",
|
||||
{"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
)
|
||||
assert ok is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert rules[0]["action"]["then"]["type"] == "variable"
|
||||
|
||||
|
||||
def test_append_action_to_screen_trigger_returns_false_for_an_unknown_screen(client, tmp_game_slug_cleanup):
|
||||
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
assert screens.append_action_to_screen_trigger(slug, 999999, "d_1", {"type": "dialogue"}) is False
|
||||
|
||||
|
||||
def test_full_game_payload_exposes_screen_triggers(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
payload = screens.full_game_payload(slug)
|
||||
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||||
assert scene_screen["screen_triggers"] == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_triggers_get_route_returns_the_saved_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_triggers_post_route_appends_a_rule(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers",
|
||||
json={"rule": {"trigger": "affichage", "action": {"type": "dialogue"}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_trigger_delete_one_route_removes_only_the_targeted_rule(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [
|
||||
{"trigger": "affichage", "action": {"type": "dialogue"}},
|
||||
{"trigger": "affichage", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
|
||||
])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/0")
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert len(rules) == 1
|
||||
assert rules[0]["action"]["type"] == "son"
|
||||
|
||||
|
||||
def test_screen_trigger_delete_one_route_404s_for_an_out_of_range_index(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/0")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_screen_triggers_delete_route_clears_all_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_screen_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
|
||||
resp1 = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
leaf_id = resp1.get_json()[0]["action"]["id"]
|
||||
assert leaf_id
|
||||
|
||||
resp2 = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
assert resp2.get_json()[0]["action"]["id"] == leaf_id # stable
|
||||
|
||||
append_resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers/append-action",
|
||||
json={"after_id": leaf_id, "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
)
|
||||
assert append_resp.status_code == 200
|
||||
|
||||
|
||||
def test_screen_triggers_get_route_404s_for_an_unknown_screen(client, tmp_game_slug_cleanup):
|
||||
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.get(f"/game/{slug}/screens/999999/triggers")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_screen_trigger_dialogue_update_route_edits_the_matching_dialogue(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers/dialogue",
|
||||
json={"dialogue_id": "d_1", "lines": [{"speaker": "narrateur", "text": "Bienvenue !"}]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "narrateur", "text": "Bienvenue !"}]
|
||||
|
||||
|
||||
def test_screen_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1")
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_screen_trigger_remove_action_route_404s_for_an_unknown_leaf_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/action/d_absent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_screen_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1/move", json={"direction": "up"})
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
action = screens.resolve_screen_triggers(screen)[0]["action"]
|
||||
assert action["type"] == "dialogue"
|
||||
assert action["then"]["type"] == "attendre"
|
||||
|
||||
|
||||
def test_screen_trigger_move_action_route_400s_for_an_invalid_direction(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1/move", json={"direction": "sideways"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_screen_trigger_append_action_route_attaches_a_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert rules[0]["action"]["then"] == {"type": "son", "asset_url": "/assets/1/bip.mp3"}
|
||||
+96
-3
@@ -295,7 +295,11 @@ def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_ga
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then(client, tmp_game_slug_cleanup):
|
||||
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
|
||||
n'importe quel niveau du schéma" — poser "+ Ajouter une action" sur
|
||||
un bloc qui a déjà une suite INSÈRE la nouvelle action devant cette
|
||||
suite plutôt que d'échouer (comportement précédent)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
@@ -310,9 +314,11 @@ def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(clien
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
||||
action = screens.resolve_collision_rules(obj)[0]["action"]
|
||||
assert action["then"]["target_variable"] == "vies"
|
||||
assert action["then"]["then"]["target_variable"] == "score"
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
|
||||
@@ -320,6 +326,89 @@ def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_1")
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_trigger_remove_action_route_404s_for_an_unknown_leaf_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_absent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
|
||||
resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "up"})
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
action = screens.resolve_collision_rules(obj)[0]["action"]
|
||||
assert action["type"] == "dialogue"
|
||||
assert action["then"]["target_variable"] == "score"
|
||||
|
||||
|
||||
def test_trigger_move_action_route_400s_for_an_invalid_direction(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "sideways"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp_game_slug_cleanup):
|
||||
"""Bug corrigé : une action "variable" posée sans "id" (par une
|
||||
session antérieure ou par l'IA) restait à jamais impossible à
|
||||
adresser via append-action — le premier GET /triggers doit la rendre
|
||||
adressable, DE FAÇON STABLE (persisté, pas régénéré à chaque appel)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
|
||||
resp1 = client.get(f"/game/{slug}/triggers")
|
||||
leaf_id = resp1.get_json()[0]["chain"][0]["id"]
|
||||
assert leaf_id
|
||||
|
||||
resp2 = client.get(f"/game/{slug}/triggers")
|
||||
assert resp2.get_json()[0]["chain"][0]["id"] == leaf_id # stable, pas régénéré
|
||||
|
||||
append_resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": leaf_id, "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
)
|
||||
assert append_resp.status_code == 200
|
||||
|
||||
|
||||
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_personnage(client, slug, screen_id, role="pnj")
|
||||
@@ -350,6 +439,10 @@ def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/append-action", json={})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1")
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1/move", json={"direction": "up"})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
|
||||
@@ -3,10 +3,54 @@ COMPTE, distincte du dossier d'un jeu (routes/uploads/upload_file.py) :
|
||||
réutilisable d'un projet à l'autre du MÊME compte, jamais visible par un
|
||||
autre."""
|
||||
import io
|
||||
import struct
|
||||
import wave
|
||||
|
||||
import auth
|
||||
|
||||
|
||||
def _png_bytes(width, height):
|
||||
"""Un PNG minimal — seule la signature + l'en-tête IHDR (largeur/
|
||||
hauteur) comptent pour auth.image_dimensions, le reste du fichier
|
||||
n'a pas besoin d'être une image RÉELLEMENT décodable (voir
|
||||
add_scene_object.py, qui ne fait que lire ces dimensions, jamais le
|
||||
pixel lui-même)."""
|
||||
ihdr = struct.pack(">II", width, height) + b"\x08\x02\x00\x00\x00"
|
||||
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IHDR" + ihdr + b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def _mp4_box(box_type, payload):
|
||||
return struct.pack(">I4s", len(payload) + 8, box_type) + payload
|
||||
|
||||
|
||||
def _mp4_bytes(seconds, timescale=1000):
|
||||
"""Un MP4 minimal — juste "ftyp" + "moov/mvhd" (durée/timescale),
|
||||
suffisant pour que mutagen (auth/validate_video_duration.py) lise sa
|
||||
durée, sans piste vidéo/audio réelle ni "mdat" (jamais décodé, seule
|
||||
la durée annoncée compte ici)."""
|
||||
ftyp = _mp4_box(b"ftyp", b"isom" + struct.pack(">I", 0) + b"isom" + b"mp42")
|
||||
duration = int(seconds * timescale)
|
||||
mvhd_payload = (
|
||||
struct.pack(">I", 0) + struct.pack(">II", 0, 0)
|
||||
+ struct.pack(">II", timescale, duration) + b"\x00" * 80
|
||||
)
|
||||
moov = _mp4_box(b"moov", _mp4_box(b"mvhd", mvhd_payload))
|
||||
return ftyp + moov
|
||||
|
||||
|
||||
def _wav_bytes(seconds, framerate=8000):
|
||||
"""Un WAV de silence, durée EXACTE (au sample près) — 8kHz mono 8-bit
|
||||
pour rester petit même sur 3+ minutes (voir test de la limite de
|
||||
durée d'un son, auth/validate_audio_duration.py)."""
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(1)
|
||||
w.setframerate(framerate)
|
||||
w.writeframes(b"\x80" * int(seconds * framerate))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_create_and_list_user_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
@@ -60,6 +104,70 @@ def test_upload_user_asset_route_requires_a_file(client):
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------- scene_kind ("fond"/"decor" — demande explicite : "un bouton
|
||||
# d'import séparé pour les images de fond et les objets") ----------
|
||||
|
||||
def test_create_user_asset_stores_the_scene_kind(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="fond")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == asset_id)
|
||||
assert asset["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_create_user_asset_scene_kind_defaults_to_none(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ancienne.png", source="upload")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == asset_id)
|
||||
assert asset["scene_kind"] is None
|
||||
|
||||
|
||||
def test_upload_route_stores_the_posted_scene_kind_for_an_image(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "ciel.png"), "scene_kind": "fond"},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
|
||||
assert asset["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_upload_route_defaults_an_image_without_scene_kind_to_decor(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
|
||||
assert asset["scene_kind"] == "decor"
|
||||
|
||||
|
||||
def test_upload_route_ignores_scene_kind_for_a_non_image_file(client):
|
||||
"""scene_kind n'a de sens que pour une IMAGE (voir
|
||||
auth/user_asset_kind.py) — un son/une vidéo ne "posent" rien sur la
|
||||
scène, peu importe ce que le client aurait posté."""
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_wav_bytes(2)), "bip.wav"), "scene_kind": "fond"},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
|
||||
assert asset["scene_kind"] is None
|
||||
|
||||
|
||||
def test_serve_user_asset_route_403s_for_another_account(client, user_client):
|
||||
# Un compte "user" fraîchement inscrit (sans projet) est redirigé vers
|
||||
# l'onboarding pour toute route hors _REACHABLE_WITHOUT_PROJECT (voir
|
||||
@@ -78,3 +186,236 @@ def test_serve_user_asset_route_403s_for_another_account(client, user_client):
|
||||
url = resp.get_json()["url"]
|
||||
resp2 = user_client.get(url)
|
||||
assert resp2.status_code == 403
|
||||
|
||||
|
||||
# ---------- "Mes assets" — upload d'un son, limité à 3 minutes (demande
|
||||
# explicite) ----------
|
||||
|
||||
def test_user_asset_kind_classifies_by_extension():
|
||||
assert auth.user_asset_kind("photo.PNG") == "image"
|
||||
assert auth.user_asset_kind("bip.mp3") == "audio"
|
||||
assert auth.user_asset_kind("bip.WAV") == "audio"
|
||||
assert auth.user_asset_kind("notes.txt") == "other"
|
||||
|
||||
|
||||
def test_validate_audio_duration_accepts_a_short_wav():
|
||||
content = _wav_bytes(2)
|
||||
assert auth.validate_audio_duration(content) is None
|
||||
|
||||
|
||||
def test_validate_audio_duration_rejects_a_wav_over_the_limit():
|
||||
content = _wav_bytes(200) # > 180s (auth.MAX_AUDIO_SECONDS)
|
||||
error = auth.validate_audio_duration(content)
|
||||
assert error is not None
|
||||
assert "3 minutes" in error
|
||||
|
||||
|
||||
def test_validate_audio_duration_rejects_unrecognized_content():
|
||||
assert auth.validate_audio_duration(b"pas-un-vrai-fichier-audio") is not None
|
||||
|
||||
|
||||
def test_upload_route_accepts_a_short_audio_file(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_wav_bytes(2)), "bip.wav")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["filename"].endswith(".wav") for a in assets)
|
||||
|
||||
|
||||
def test_upload_route_rejects_an_audio_file_over_three_minutes(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_wav_bytes(200)), "trop-long.wav")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "3 minutes" in resp.get_json()["error"]
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert not any(a["original_name"] == "trop-long.wav" for a in assets)
|
||||
|
||||
|
||||
# ---------- "Mes assets" — upload d'une vidéo, format MP4 uniquement,
|
||||
# limité à 5 minutes (demande explicite) ----------
|
||||
|
||||
def test_validate_video_duration_accepts_a_short_mp4():
|
||||
assert auth.validate_video_duration(_mp4_bytes(30)) is None
|
||||
|
||||
|
||||
def test_validate_video_duration_rejects_an_mp4_over_the_limit():
|
||||
error = auth.validate_video_duration(_mp4_bytes(400))
|
||||
assert error is not None
|
||||
assert "5 minutes" in error
|
||||
|
||||
|
||||
def test_validate_video_duration_rejects_unrecognized_content():
|
||||
assert auth.validate_video_duration(b"pas-une-vraie-video") is not None
|
||||
|
||||
|
||||
def test_upload_route_accepts_a_short_mp4_video(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_mp4_bytes(30)), "clip.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["filename"].endswith(".mp4") for a in assets)
|
||||
|
||||
|
||||
def test_upload_route_rejects_an_mp4_video_over_five_minutes(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_mp4_bytes(400)), "trop-long.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "5 minutes" in resp.get_json()["error"]
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert not any(a["original_name"] == "trop-long.mp4" for a in assets)
|
||||
|
||||
|
||||
def test_upload_route_rejects_a_non_mp4_video_format(client):
|
||||
"""Demande explicite : "une vidéo au format MP4" — webm/mov/avi sont
|
||||
reconnus comme "vidéo" par auth/user_asset_kind.py (rétrocompatibilité
|
||||
de l'organisation "Mes assets"), mais plus ACCEPTÉS à l'upload."""
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"peu-importe-le-contenu"), "clip.webm")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "MP4" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_upload_route_rejects_unrecognized_mp4_content(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"pas-une-vraie-video"), "faux.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------- Suppression d'un asset (demande explicite : "voir, utiliser
|
||||
# ou supprimer des son, image et vidéo") ----------
|
||||
|
||||
def test_delete_user_asset_route_removes_it(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "abc123.png", original_name="photo.png", source="upload")
|
||||
resp = client.delete(f"/assets/{asset_id}")
|
||||
assert resp.status_code == 200
|
||||
assert auth.get_user_asset(asset_id) is None
|
||||
|
||||
|
||||
def test_delete_user_asset_route_404s_for_an_unknown_id(client):
|
||||
resp = client.delete("/assets/999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_user_asset_route_refuses_another_accounts_asset(client, user_client):
|
||||
# Un compte "user" fraîchement inscrit (sans projet) est redirigé vers
|
||||
# l'onboarding pour toute route hors _REACHABLE_WITHOUT_PROJECT (voir
|
||||
# core/auth_guard.py) — lui assigner un projet isole ce test sur le
|
||||
# SEUL comportement voulu : l'isolation par propriétaire.
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
auth.set_project_slug(other_user_id, "pytest_dummy_project_delete")
|
||||
asset_id = auth.create_user_asset(admin_id, "admin_only.png")
|
||||
resp = user_client.delete(f"/assets/{asset_id}")
|
||||
assert resp.status_code == 404
|
||||
assert auth.get_user_asset(asset_id) is not None
|
||||
|
||||
|
||||
# ---------- Reclasser un asset DÉJÀ importé entre "Fonds" et
|
||||
# "Décors/objets" sans ré-upload (diagnostiqué depuis un "décalage" : une
|
||||
# image importée par le mauvais bouton, ou avant la séparation fond/décor,
|
||||
# restait bloquée dans la mauvaise section) ----------
|
||||
|
||||
def test_update_user_asset_scene_kind_reclassifies_the_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
assert auth.update_user_asset_scene_kind(asset_id, user_id, "fond") is True
|
||||
asset = auth.get_user_asset(asset_id)
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert next(a for a in assets if a["id"] == asset_id)["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_update_user_asset_scene_kind_refuses_when_not_the_owner(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(admin_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
assert auth.update_user_asset_scene_kind(asset_id, other_user_id, "fond") is False
|
||||
|
||||
|
||||
def test_update_user_asset_scene_kind_refuses_a_non_image_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "bip.mp3", source="upload")
|
||||
assert auth.update_user_asset_scene_kind(asset_id, user_id, "fond") is False
|
||||
|
||||
|
||||
def test_set_scene_kind_route_reclassifies_the_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
resp = client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "fond"})
|
||||
assert resp.status_code == 200
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert next(a for a in assets if a["id"] == asset_id)["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_set_scene_kind_route_rejects_an_invalid_value(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
resp = client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "autre"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_set_scene_kind_route_404s_for_another_accounts_asset(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
auth.set_project_slug(other_user_id, "pytest_dummy_project_scene_kind")
|
||||
asset_id = auth.create_user_asset(admin_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
resp = user_client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "fond"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------- Dimensions réelles d'une image importée (bug : "je ne peux
|
||||
# pas poser un objet hors du champ de la caméra quand j'utilise un fond
|
||||
# de scène importé" — voir auth/image_dimensions.py) ----------
|
||||
|
||||
def test_image_dimensions_reads_a_png_header():
|
||||
assert auth.image_dimensions(_png_bytes(1920, 1080)) == (1920, 1080)
|
||||
|
||||
|
||||
def test_image_dimensions_returns_none_for_unrecognized_content():
|
||||
assert auth.image_dimensions(b"pas-une-image") is None
|
||||
|
||||
|
||||
def test_upload_route_rejects_unrecognized_audio_content(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"pas-un-vrai-son"), "faux.mp3")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user