Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- 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:
william
2026-09-12 00:28:46 +02:00
co-authored by Claude Sonnet 5
parent b07b231a61
commit 559331f9cf
67 changed files with 4739 additions and 306 deletions
+4 -2
View File
@@ -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:
+2
View File
@@ -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é.
+66 -13
View File
@@ -37,25 +37,61 @@ _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 "
"écran, et les variables globales déjà existantes — relis-les avant "
"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)."
"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 "
@@ -84,7 +120,14 @@ _SYSTEM_PROMPT = (
"vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN "
"personnage avec role=\"joueur\" (set_object_role) — jamais deux, "
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
"demandé."
"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
View File
@@ -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
View File
@@ -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",
]
+11 -4
View File
@@ -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()
+11
View File
@@ -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()
+71
View File
@@ -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
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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
+29
View File
@@ -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
+25
View File
@@ -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"
+25
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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):
+5
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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})
+32 -1
View File
@@ -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),
+38 -3
View File
@@ -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),
)
+47
View File
@@ -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({
+11 -1
View File
@@ -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
View File
@@ -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
+132
View File
@@ -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})
+34 -8
View File
@@ -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})
+10
View File
@@ -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,
+12
View File
@@ -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)
+9
View File
@@ -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)
+7
View File
@@ -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
+400 -37
View File
@@ -1,8 +1,12 @@
import json
import uuid
from db.dialogue_lines import sanitize_dialogue_lines
from screens.flow.constants import CONDITION_OPERATORS
from screens.labels.data_operations import DATA_OPERATION_LABELS
from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS
from screens.labels.surbrillance_values import SURBRILLANCE_LABELS
from screens.labels.video_modes import VIDEO_MODE_LABELS
# Éditeur de déclencheurs (voir static/js/triggers/trigger-editor.js::
# openTriggerWizard et static/js/scenes/collision-rule-wizard.js) : chaque
@@ -10,10 +14,17 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
# personnage_role.py — et un "fond", jamais une cible/source de collision)
# porte une LISTE de règles "déclencheur -> action".
#
# Déclencheur : "collision" (chevauchement direct, voir
# static/js/play/conditions.js::forgeShapesOverlap) — seul déclencheur
# encore proposé ("perimetre" retiré, ne servait à rien pour l'instant,
# demande explicite).
# Déclencheur :
# - "collision" : chevauchement direct avec le joueur (voir
# static/js/play/conditions.js::forgeShapesOverlap) —
# nécessite un personnage "joueur" sur l'écran
# ("perimetre" retiré, ne servait à rien pour l'instant,
# demande explicite).
# - "clic" : l'objet est cliqué/touché — pas besoin de joueur, écouteur
# posé directement sur l'objet (voir static/js/play/
# collision-rules-controller.js::forgeBindClicSurvolRules).
# - "survol" : le pointeur survole l'objet (souris) — même remarque
# que "clic".
#
# Action :
# - "dialogue" : la logique n'est plus portée par une "quête" mais
@@ -53,12 +64,43 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
# screens/flow/constants.py::CONDITION_OPERATORS, même
# vocabulaire que le nœud Condition de l'éditeur de
# logique) -> exécute `si_vrai` ou `si_faux` (chacun soit
# None — "ne rien faire", soit une FEUILLE — "dialogue"
# ou "variable", voir maquette validée, JAMAIS une
# nouvelle "interagir"/"condition" imbriquée : une
# branche reste une feuille).
# None — "ne rien faire", soit une FEUILLE (voir
# LEAF_ACTION_TYPES ci-dessous), voir maquette validée,
# JAMAIS une nouvelle "interagir"/"condition" imbriquée :
# une branche reste une feuille).
# - "surbrillance" : met un objet en surbrillance (n'importe lequel de
# l'écran, pas forcément celui qui porte la règle — voir
# `object_id`). MÊME vocabulaire ("on"/"off"/"toggle",
# classe CSS .forgeHighlight) que la propriété
# "surbrillance" de "Modifier un élément" (voir
# static/js/play/actions.js::applyElementProperty,
# screens/labels/surbrillance_values.py), réutilisée telle
# quelle plutôt que réinventée.
# - "visibilite" : rend un objet visible/masqué/invisible (voir
# `object_id`, screens/labels/element_visibility.py::
# ELEMENT_VISIBILITY_VALUES, même vocabulaire que
# "Modifier un élément" côté Flow).
# - "son" : joue un effet sonore ponctuel, non bouclé (voir
# `asset_url`) — même comportement que l'action "Jouer un
# son" du Flow (static/js/play/actions.js, `new
# Audio(url).play()`), indépendant de la musique de fond.
# - "video" : joue une vidéo uploadée dans "Mes assets" (voir
# `asset_url`, `mode` ∈ VIDEO_MODES) — bloque la suite de
# la chaîne "then" jusqu'à la fin de la vidéo (ou "passer"),
# comme un dialogue.
# - "indication": affiche une bulle de texte courte près d'un objet (voir
# `object_id`, `texte`, `duree_ms` optionnel — disparaît
# automatiquement après ce délai, par défaut voir
# static/js/play/collision-rules-controller.js) — même
# esprit que la bulle "Appuie sur [touche]" déjà utilisée
# par "interagir".
# - "attendre" : suspend la chaîne "then" pendant `data_value` SECONDES
# (même vocabulaire que l'action "attendre" du Flow, voir
# static/js/play/actions.js) avant de continuer — utile
# par ex. pour laisser un temps de lecture après une
# narration à l'affichage de l'écran.
#
# CHAÎNAGE : une feuille ("dialogue" ou "variable") peut porter un champ
# CHAÎNAGE : une FEUILLE (voir LEAF_ACTION_TYPES) peut porter un champ
# optionnel "then" — l'action suivante à exécuter une fois celle-ci
# terminée, elle-même une feuille, qui peut à son tour avoir un "then"
# (borné par _MAX_THEN_CHAIN) — demande explicite : "pouvoir modifier un
@@ -67,15 +109,25 @@ from screens.labels.data_operations import DATA_OPERATION_LABELS
#
# ("attaque"/"evenement" retirés — ne servaient à rien pour l'instant,
# demande explicite.)
ACTION_TYPES = ["dialogue", "variable", "interagir", "condition"]
LEAF_ACTION_TYPES = ("dialogue", "variable")
TRIGGER_TYPES = ["collision"]
ACTION_TYPES = [
"dialogue", "variable", "surbrillance", "visibilite", "son", "video", "indication", "attendre",
"interagir", "condition",
]
TRIGGER_TYPES = ["collision", "clic", "survol"]
CONDITION_OPERATOR_KEYS = {key for key, _label in CONDITION_OPERATORS}
# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une
# structure sans fin construite par un JSON posté à la main.
_MAX_ACTION_DEPTH = 2
# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH.
_MAX_THEN_CHAIN = 4
# Longueur max d'une chaîne de "then" — même esprit que _MAX_ACTION_DEPTH
# (jamais illimité, mais large : voir "+ Ajouter une action" posé après
# CHAQUE bloc, demande explicite "n'importe quel niveau du schéma"). Bug
# corrigé : à 4, une chaîne suffisait à peine à enchaîner attendre ->
# dialogue -> surbrillance -> attendre — le 5e maillon ajouté par
# l'utilisateur (ex. une seconde surbrillance pour désactiver) était
# TRONQUÉ EN SILENCE par la resanitisation qui suit chaque écriture
# (set_then_action_in_rules), donnant l'impression que l'ajout n'avait
# aucun effet alors qu'il avait bien été inséré en mémoire.
_MAX_THEN_CHAIN = 30
_MAX_ACTION_ID_LENGTH = 40
@@ -129,20 +181,112 @@ def _sanitize_variable_action(raw_action):
return action
def _sanitize_object_id(raw_action):
object_id = raw_action.get("object_id")
try:
return int(object_id)
except (TypeError, ValueError):
return None
def _with_optional_id(action, raw_action):
"""Ajoute le champ `id` (optionnel, généré côté client) commun à
toutes les feuilles autres que "dialogue" — sert à adresser CETTE
feuille précise pour y attacher un "then" plus tard (voir
set_then_action_in_rules), même rôle que `variable.id`."""
action_id = raw_action.get("id")
if isinstance(action_id, str) and action_id.strip():
action["id"] = action_id.strip()[:_MAX_ACTION_ID_LENGTH]
return action
def _sanitize_surbrillance_action(raw_action):
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in SURBRILLANCE_LABELS:
return None
return _with_optional_id({"type": "surbrillance", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_visibilite_action(raw_action):
object_id = _sanitize_object_id(raw_action)
valeur = raw_action.get("valeur")
if object_id is None or valeur not in ELEMENT_VISIBILITY_LABELS:
return None
return _with_optional_id({"type": "visibilite", "object_id": object_id, "valeur": valeur}, raw_action)
def _sanitize_son_action(raw_action):
asset_url = raw_action.get("asset_url")
if not isinstance(asset_url, str) or not asset_url.strip():
return None
return _with_optional_id({"type": "son", "asset_url": asset_url.strip()}, raw_action)
def _sanitize_video_action(raw_action):
asset_url = raw_action.get("asset_url")
mode = raw_action.get("mode")
if not isinstance(asset_url, str) or not asset_url.strip() or mode not in VIDEO_MODE_LABELS:
return None
return _with_optional_id({"type": "video", "asset_url": asset_url.strip(), "mode": mode}, raw_action)
def _sanitize_indication_action(raw_action):
object_id = _sanitize_object_id(raw_action)
texte = raw_action.get("texte")
if object_id is None or not isinstance(texte, str) or not texte.strip():
return None
action = {"type": "indication", "object_id": object_id, "texte": texte.strip()[:200]}
duree_ms = raw_action.get("duree_ms")
if isinstance(duree_ms, (int, float)) and not isinstance(duree_ms, bool) and duree_ms > 0:
action["duree_ms"] = int(duree_ms)
return _with_optional_id(action, raw_action)
def _sanitize_attendre_action(raw_action):
""""Attendre N secondes" avant de continuer la chaîne — MÊME
vocabulaire que l'action "attendre" du Flow (voir static/js/play/
actions.js, `data_value` en SECONDES, pas en ms) : suspend le "then"
plutôt que de l'exécuter immédiatement (comme "video"), utile par ex.
pour une narration à l'affichage de l'écran qui doit laisser un temps
de lecture avant d'enchaîner."""
raw_value = raw_action.get("data_value")
try:
seconds = float(raw_value)
except (TypeError, ValueError):
return None
if seconds < 0:
return None
return _with_optional_id({"type": "attendre", "data_value": str(seconds)}, raw_action)
# Une seule table de vérité pour les feuilles : ajouter un type ici suffit
# (voir LEAF_ACTION_TYPES dérivé de ses clés) — évite le if/elif qui aurait
# dû être répété à chaque nouvel ajout.
_LEAF_ACTION_SANITIZERS = {
"dialogue": _sanitize_dialogue_action,
"variable": _sanitize_variable_action,
"surbrillance": _sanitize_surbrillance_action,
"visibilite": _sanitize_visibilite_action,
"son": _sanitize_son_action,
"video": _sanitize_video_action,
"indication": _sanitize_indication_action,
"attendre": _sanitize_attendre_action,
}
LEAF_ACTION_TYPES = tuple(_LEAF_ACTION_SANITIZERS)
def _sanitize_leaf_action(raw_action, depth=1):
"""Sanitize une FEUILLE ("dialogue" ou "variable") et son éventuelle
"""Sanitize une FEUILLE (voir LEAF_ACTION_TYPES) et son éventuelle
chaîne "then" (bornée à _MAX_THEN_CHAIN) — utilisée partout où une
feuille est attendue : action de premier niveau, sous "interagir", ou
branche Vrai/Faux de "condition"."""
if not isinstance(raw_action, dict):
return None
action_type = raw_action.get("type")
if action_type == "dialogue":
action = _sanitize_dialogue_action(raw_action)
elif action_type == "variable":
action = _sanitize_variable_action(raw_action)
else:
sanitizer = _LEAF_ACTION_SANITIZERS.get(raw_action.get("type"))
if not sanitizer:
return None
action = sanitizer(raw_action)
if not action:
return None
if depth < _MAX_THEN_CHAIN:
@@ -193,11 +337,17 @@ def _sanitize_action(raw_action, depth=1):
}
def sanitize_collision_rules(raw_rules):
def sanitize_collision_rules(raw_rules, allowed_triggers=None):
"""Valide/nettoie une liste de règles postée par le client (voir
routes/triggers/) — une règle invalide (déclencheur/action inconnus,
imbrication trop profonde...) est IGNORÉE plutôt que de faire planter
la sauvegarde entière."""
la sauvegarde entière. `allowed_triggers` (par défaut TRIGGER_TYPES) :
réutilisé tel quel par screens/rendering/screen_triggers.py pour les
déclencheurs D'ÉCRAN (sans objet), qui partagent la même logique de
sanitisation d'action mais un vocabulaire de déclencheur différent
(seulement "affichage")."""
if allowed_triggers is None:
allowed_triggers = TRIGGER_TYPES
if not isinstance(raw_rules, list):
return []
cleaned = []
@@ -205,7 +355,7 @@ def sanitize_collision_rules(raw_rules):
if not isinstance(raw_rule, dict):
continue
trigger = raw_rule.get("trigger")
if trigger not in TRIGGER_TYPES:
if trigger not in allowed_triggers:
continue
action = _sanitize_action(raw_rule.get("action"))
if not action:
@@ -224,7 +374,7 @@ def _apply_dialogue_lines(action, dialogue_id, lines):
action["dialogue"] = {"id": dialogue_id, "lines": lines}
return True
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
if action_type == "variable":
if action_type in LEAF_ACTION_TYPES:
return _apply_dialogue_lines(action.get("then"), dialogue_id, lines)
if action_type == "interagir":
return _apply_dialogue_lines(action.get("sub_action"), dialogue_id, lines)
@@ -255,7 +405,7 @@ def _leaf_id(action):
if action.get("type") == "dialogue":
dialogue = action.get("dialogue")
return dialogue.get("id") if isinstance(dialogue, dict) else None
if action.get("type") == "variable":
if action.get("type") in LEAF_ACTION_TYPES:
return action.get("id")
return None
@@ -266,8 +416,15 @@ def _apply_then_action(action, leaf_id, then_action):
action_type = action.get("type")
if action_type in LEAF_ACTION_TYPES:
if _leaf_id(action) == leaf_id:
if "then" in action:
return False # jamais écraser un chaînage déjà posé
# INSÈRE `then_action` juste après cette feuille — l'éventuelle
# suite déjà présente passe DERRIÈRE elle plutôt que d'être
# écrasée (demande explicite : "pouvoir ajouter n'importe
# quelle action à n'importe quel niveau du schéma", pas
# seulement en bout de chaîne — comportement précédent, qui
# refusait si "then" existait déjà).
existing_then = action.get("then")
if existing_then:
then_action["then"] = existing_then
action["then"] = then_action
return True
return _apply_then_action(action.get("then"), leaf_id, then_action)
@@ -282,15 +439,16 @@ def _apply_then_action(action, leaf_id, then_action):
def set_then_action_in_rules(rules, leaf_id, raw_then_action):
"""Attache EN PLACE une NOUVELLE action `then` sur la feuille
("dialogue" ou "variable") identifiée par `leaf_id` (dialogue.id pour
une feuille dialogue, id pour une feuille variable) — n'importe où
dans l'arbre d'action d'une des règles. Utilisé par routes/triggers/
pour "+ Ajouter une action" en bout de chaîne (voir static/js/triggers/
trigger-editor.js). Retourne False sans rien modifier si `leaf_id`
est introuvable, OU si la feuille trouvée a DÉJÀ un "then" (on
n'écrase jamais un chaînage existant par cette route — seulement
ajouter à la fin réelle de la chaîne)."""
"""INSÈRE EN PLACE une NOUVELLE action juste après la feuille
identifiée par `leaf_id` (dialogue.id pour une feuille dialogue, id
pour toute autre feuille) — n'importe où dans l'arbre d'action d'une
des règles. Utilisé par "+ Ajouter une action", posé après CHAQUE
bloc de la chaîne (voir static/js/triggers/trigger-editor.js) : si la
feuille visée a déjà une suite ("then"), celle-ci passe DERRIÈRE la
nouvelle action plutôt que d'être remplacée — permet d'insérer une
action à N'IMPORTE QUEL niveau, pas seulement en bout de chaîne
réelle. Retourne False sans rien modifier si `leaf_id` est
introuvable."""
then_action = _sanitize_leaf_action(raw_then_action)
if not then_action:
return False
@@ -300,6 +458,211 @@ def set_then_action_in_rules(rules, leaf_id, raw_then_action):
return False
# ---------- Suppression/réordonnancement d'un maillon précis (demande
# explicite : "que chaque bloc étiquette + action soit déplaçable ou
# supprimable par l'utilisateur ET par l'IA") ----------
# Les deux opérations ci-dessous reconstruisent l'arbre par le HAUT
# (renvoient un NOUVEL arbre plutôt que de muter les dicts en place, voir
# _remove_leaf_id/_move_in_action_tree) — plus sûr qu'un patch en place
# pour un maillon qui peut être la TÊTE d'une règle (auquel cas la
# RÈGLE elle-même doit être remplacée ou retirée, pas juste un champ
# interne).
def _remove_leaf_id(action, leaf_id):
"""Retire la feuille `leaf_id` de l'arbre `action` — renvoie
(nouvel_arbre_ou_None, trouvé). `nouvel_arbre` vaut None si `action`
lui-même était la cible et n'avait rien pour le remplacer (ni "then",
ni sous-action restante pour "interagir")."""
if not isinstance(action, dict):
return action, False
action_type = action.get("type")
if action_type in LEAF_ACTION_TYPES:
if _leaf_id(action) == leaf_id:
return action.get("then"), True
new_then, removed = _remove_leaf_id(action.get("then"), leaf_id)
if not removed:
return action, False
new_action = dict(action)
if new_then:
new_action["then"] = new_then
else:
new_action.pop("then", None)
return new_action, True
if action_type == "interagir":
new_sub, removed = _remove_leaf_id(action.get("sub_action"), leaf_id)
if not removed:
return action, False
if new_sub is None:
# Plus de sous-action : "interagir" perd son sens (une touche
# qui ne déclenche plus rien), retiré à son tour.
return None, True
new_action = dict(action)
new_action["sub_action"] = new_sub
return new_action, True
if action_type == "condition":
new_action = dict(action)
changed = False
for branch_key in ("si_vrai", "si_faux"):
new_branch, removed = _remove_leaf_id(action.get(branch_key), leaf_id)
if removed:
new_action[branch_key] = new_branch
changed = True
return (new_action, True) if changed else (action, False)
return action, False
def remove_action_from_rules(rules, leaf_id):
"""Retire EN PLACE l'action `leaf_id` n'importe où dans l'arbre d'une
des règles (feuille directe d'une règle, maillon d'une chaîne "then",
sous-action d'un "interagir", ou dans une branche de "condition"). Si
l'action retirée était la TÊTE d'une règle : la règle est remplacée
par sa suite ("then") si elle en avait une, sinon la RÈGLE ENTIÈRE
disparaît (plus rien à garder). Renvoie True si quelque chose a été
retiré."""
for i, rule in enumerate(rules):
new_action, removed = _remove_leaf_id(rule.get("action"), leaf_id)
if not removed:
continue
if new_action is None:
del rules[i]
else:
rules[i] = {"trigger": rule["trigger"], "action": new_action}
return True
return False
def _chain_as_list(head_action):
"""Aplatit une chaîne de FEUILLES pures (voir LEAF_ACTION_TYPES) en
liste — le réordonnancement ne s'applique qu'à l'intérieur d'une
chaîne "then" (jamais entre deux branches différentes d'une
condition, ni entre une "interagir" et ce qui l'entoure)."""
items = []
node = head_action
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
items.append(node)
node = node.get("then")
return items
def _relink_chain(items):
items = [dict(n) for n in items]
for i in range(len(items) - 1):
items[i]["then"] = items[i + 1]
if items:
items[-1].pop("then", None)
return items[0] if items else None
def _move_in_action_tree(action, leaf_id, direction):
"""Cherche, dans `action`, LA chaîne "then" qui contient `leaf_id`
(au premier niveau, sous une "interagir", ou dans une branche de
"condition"), et échange `leaf_id` avec son voisin immédiat
("up"/"down") DANS CETTE CHAÎNE. Renvoie le nouvel arbre, ou None si
`leaf_id` n'a été trouvé nulle part dans `action` (jamais rien à
changer dans ce cas — distinct d'un déplacement sans effet parce que
déjà en bout de chaîne, qui renvoie quand même un arbre, identique à
l'original)."""
if not isinstance(action, dict):
return None
action_type = action.get("type")
if action_type in LEAF_ACTION_TYPES:
chain = _chain_as_list(action)
ids = [_leaf_id(n) for n in chain]
if leaf_id not in ids:
return None
idx = ids.index(leaf_id)
swap_idx = idx - 1 if direction == "up" else idx + 1
if 0 <= swap_idx < len(chain):
chain[idx], chain[swap_idx] = chain[swap_idx], chain[idx]
return _relink_chain(chain)
if action_type == "interagir":
new_sub = _move_in_action_tree(action.get("sub_action"), leaf_id, direction)
if new_sub is None:
return None
new_action = dict(action)
new_action["sub_action"] = new_sub
return new_action
if action_type == "condition":
for branch_key in ("si_vrai", "si_faux"):
new_branch = _move_in_action_tree(action.get(branch_key), leaf_id, direction)
if new_branch is not None:
new_action = dict(action)
new_action[branch_key] = new_branch
return new_action
return None
return None
def move_action_in_rules(rules, leaf_id, direction):
"""Déplace EN PLACE la feuille `leaf_id` d'un cran vers le haut
("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then" (jamais
au-delà de sa branche de condition/sous-action interagir — voir
_move_in_action_tree). `direction` autre que "up"/"down" : aucun
effet. Renvoie True si `leaf_id` a été trouvé (même si déjà en bout
de chaîne et donc sans déplacement réel — la règle est alors
réécrite à l'identique)."""
if direction not in ("up", "down"):
return False
for i, rule in enumerate(rules):
new_action = _move_in_action_tree(rule.get("action"), leaf_id, direction)
if new_action is not None:
rules[i] = {"trigger": rule["trigger"], "action": new_action}
return True
return False
def _generate_leaf_id():
return "d_" + uuid.uuid4().hex[:12]
def _backfill_leaf_ids(action):
"""Assure EN PLACE qu'un id STABLE existe sur toute feuille non-
"dialogue" (qui a déjà le sien via dialogue.id) qui n'en avait pas —
ex. une action "variable" posée AVANT que ce champ ne devienne
systématique, ou par l'IA (le champ reste optionnel dans son schéma,
voir ai/tools.py). Sans id, une feuille reste à jamais impossible à
adresser (chaînage/ajout/retrait/déplacement ciblé, voir
"+ Ajouter une action" posé après CHAQUE bloc). Renvoie True si
quelque chose a été ajouté — l'appelant doit alors PERSISTER `action`
(voir backfill_missing_leaf_ids), sans quoi l'id resterait instable
d'une lecture à l'autre (régénéré à chaque appel)."""
if not isinstance(action, dict):
return False
changed = False
action_type = action.get("type")
if action_type in LEAF_ACTION_TYPES:
if action_type != "dialogue" and not action.get("id"):
action["id"] = _generate_leaf_id()
changed = True
if _backfill_leaf_ids(action.get("then")):
changed = True
elif action_type == "interagir":
if _backfill_leaf_ids(action.get("sub_action")):
changed = True
elif action_type == "condition":
if _backfill_leaf_ids(action.get("si_vrai")):
changed = True
if _backfill_leaf_ids(action.get("si_faux")):
changed = True
return changed
def backfill_missing_leaf_ids(rules):
"""Applique _backfill_leaf_ids à CHAQUE règle de `rules` (mutées EN
PLACE) — renvoie True si au moins une règle a été modifiée, auquel
cas l'appelant DOIT persister `rules` (voir routes/triggers/
triggers.py::triggers_get, routes/screens/screen_triggers.py::
screen_triggers_get, screens/rendering/trigger_graph.py::
list_all_triggers — appelé à la LECTURE, pour que le premier
affichage d'une règle ancienne rende ses feuilles adressables une
bonne fois pour toutes)."""
changed = False
for rule in rules:
if _backfill_leaf_ids(rule.get("action")):
changed = True
return changed
def resolve_collision_rules(obj):
"""Décode attributes._collision_rules en liste Python, jamais None —
un attribut absent/corrompu retombe sur aucune règle (comportement
+36
View File
@@ -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)
+54 -28
View File
@@ -17,36 +17,49 @@ def _game_objects_with_thumbnails(slug, screens_mod):
return result
from .collision_rules import LEAF_ACTION_TYPES
# Champs affichés pour chaque type de feuille (voir screens/rendering/
# collision_rules.py pour la forme exacte de chacune) — un simple filtre
# sur l'action déjà sanitizée, jamais une recopie de valeur : ajouter un
# type de feuille suffit à l'ajouter ici, sans dupliquer sa logique de
# sanitisation.
_LEAF_DISPLAY_FIELDS = {
"dialogue": ("dialogue",),
"variable": ("id", "target_variable", "data_operation", "data_value"),
"surbrillance": ("id", "object_id", "valeur"),
"visibilite": ("id", "object_id", "valeur"),
"son": ("id", "asset_url"),
"video": ("id", "asset_url", "mode"),
"indication": ("id", "object_id", "texte", "duree_ms"),
"attendre": ("id", "data_value"),
}
def _describe_leaf(action):
"""Une seule feuille ("dialogue" ou "variable", voir
screens/rendering/collision_rules.py) décrite pour l'affichage,
SANS son éventuel "then" (voir _describe_chain, qui aplatit la
chaîne complète)."""
if action.get("type") == "dialogue":
return {"type": "dialogue", "dialogue": action.get("dialogue")}
return {
"type": "variable",
"id": action.get("id"),
"target_variable": action.get("target_variable"),
"data_operation": action.get("data_operation"),
"data_value": action.get("data_value"),
}
"""Une seule feuille (voir LEAF_ACTION_TYPES, screens/rendering/
collision_rules.py) décrite pour l'affichage, SANS son éventuel
"then" (voir _describe_chain, qui aplatit la chaîne complète)."""
action_type = action.get("type")
fields = _LEAF_DISPLAY_FIELDS.get(action_type, ())
return {"type": action_type, **{f: action[f] for f in fields if f in action}}
def _describe_chain(action):
"""Aplatit une feuille + ses "then" successifs en une LISTE, dans
l'ordre d'exécution — voir maquette validée ("plusieurs actions
chaînées"). Liste vide si `action` n'est ni "dialogue" ni "variable"."""
chaînées"). Liste vide si `action` n'est pas une feuille reconnue
(voir LEAF_ACTION_TYPES)."""
chain = []
node = action
while isinstance(node, dict) and node.get("type") in ("dialogue", "variable"):
while isinstance(node, dict) and node.get("type") in LEAF_ACTION_TYPES:
chain.append(_describe_leaf(node))
node = node.get("then")
return chain
def _branch_info(branch):
if not isinstance(branch, dict) or branch.get("type") not in ("dialogue", "variable"):
if not isinstance(branch, dict) or branch.get("type") not in LEAF_ACTION_TYPES:
return None
return {"chain": _describe_chain(branch)}
@@ -54,19 +67,19 @@ def _branch_info(branch):
def _resolve_action_trigger(action, has_interagir=False):
"""Décrit ENTIÈREMENT un arbre d'action (voir screens/rendering/
collision_rules.py) pour l'affichage dans l'onglet "Déclencheurs" —
chaque feuille ("dialogue"/"variable") affiche DIRECTEMENT son propre
contenu, où qu'elle soit dans l'arbre (y compris CHAQUE branche d'une
"condition", indépendamment l'une de l'autre), et sa chaîne "then"
complète (voir _describe_chain). Retourne {has_interagir, chain,
condition} — `chain` est une LISTE (jamais None, vide si l'action ne
se termine par aucune feuille reconnue) ; `condition` est None sauf
pour une action "condition", auquel cas il porte {variable, operateur,
valeur, si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne
rien faire") ou {chain: [...]}."""
chaque feuille affiche DIRECTEMENT son propre contenu, où qu'elle soit
dans l'arbre (y compris CHAQUE branche d'une "condition",
indépendamment l'une de l'autre), et sa chaîne "then" complète (voir
_describe_chain). Retourne {has_interagir, chain, condition} —
`chain` est une LISTE (jamais None, vide si l'action ne se termine
par aucune feuille reconnue) ; `condition` est None sauf pour une
action "condition", auquel cas il porte {variable, operateur, valeur,
si_vrai, si_faux} où si_vrai/si_faux valent chacun None ("ne rien
faire") ou {chain: [...]}."""
if not isinstance(action, dict):
return {"has_interagir": has_interagir, "chain": [], "condition": None}
action_type = action.get("type")
if action_type in ("dialogue", "variable"):
if action_type in LEAF_ACTION_TYPES:
return {"has_interagir": has_interagir, "chain": _describe_chain(action), "condition": None}
if action_type == "interagir":
return _resolve_action_trigger(action.get("sub_action") or {}, has_interagir=True)
@@ -96,6 +109,15 @@ def list_all_triggers(slug):
result = []
for obj in rule_objects:
# Rétro-remplissage des feuilles posées avant que "id" ne devienne
# systématique (ex. par l'IA, ce champ restant optionnel dans son
# schéma) — sans ça, "+ Ajouter une action"/le retrait/le
# déplacement restent silencieusement indisponibles sur ce bloc
# précis (voir screens/rendering/collision_rules.py::
# backfill_missing_leaf_ids). Persisté dès ce premier affichage,
# jamais régénéré ensuite.
if screens_mod.backfill_missing_leaf_ids(obj["rules"]):
screens_mod.set_scene_object_collision_rules(slug, obj["id"], obj["rules"])
for rule in obj["rules"]:
info = _resolve_action_trigger(rule["action"])
result.append({
@@ -119,7 +141,7 @@ def _collect_dialogue_lines_from_action(action, acc):
if isinstance(dialogue, dict):
acc.append(dialogue.get("lines") or [])
_collect_dialogue_lines_from_action(action.get("then"), acc)
elif action_type == "variable":
elif action_type in LEAF_ACTION_TYPES:
_collect_dialogue_lines_from_action(action.get("then"), acc)
elif action_type == "interagir":
_collect_dialogue_lines_from_action(action.get("sub_action"), acc)
@@ -145,6 +167,8 @@ def collect_all_dialogue_lines(slug):
for obj in screens_mod.list_scene_objects(slug, s["id"]):
for rule in screens_mod.resolve_collision_rules(obj):
_collect_dialogue_lines_from_action(rule.get("action"), acc)
for rule in screens_mod.resolve_screen_triggers(s):
_collect_dialogue_lines_from_action(rule.get("action"), acc)
return acc
@@ -157,7 +181,7 @@ def _collect_completable_ids_from_action(action, acc):
if action.get("mark_completed") and isinstance(dialogue, dict) and dialogue.get("id"):
acc.append(dialogue["id"])
_collect_completable_ids_from_action(action.get("then"), acc)
elif action_type == "variable":
elif action_type in LEAF_ACTION_TYPES:
_collect_completable_ids_from_action(action.get("then"), acc)
elif action_type == "interagir":
_collect_completable_ids_from_action(action.get("sub_action"), acc)
@@ -182,6 +206,8 @@ def list_completable_dialogue_ids(slug):
for obj in screens_mod.list_scene_objects(slug, s["id"]):
for rule in screens_mod.resolve_collision_rules(obj):
_collect_completable_ids_from_action(rule.get("action"), acc)
for rule in screens_mod.resolve_screen_triggers(s):
_collect_completable_ids_from_action(rule.get("action"), acc)
return acc
+44 -10
View File
@@ -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"]
+12 -10
View File
@@ -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
+18
View File
@@ -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
+8
View File
@@ -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é)', () => {
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' } }] }] }));
// "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');
});
+116 -2
View File
@@ -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;
}
+20
View File
@@ -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 };
}
+4
View File
@@ -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
+58
View File
@@ -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')"));
});
+300 -72
View File
@@ -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, '&lt;') + '</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, '&lt;') + '</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);
});
});
}
+121 -4
View File
@@ -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';
}
}
});
}
+428 -62
View File
@@ -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, '&quot;') + '"' + (a.url === audioUrl ? ' selected' : '') + '>' + a.name.replace(/</g, '&lt;') + '</option>';
}).join('');
var preview = audioUrl
? '<audio controls preload="none" src="' + audioUrl.replace(/"/g, '&quot;') + '" 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, '&lt;') + '</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, '&lt;');
}
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, '&lt;'));
}
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, '&lt;') + ' ' + (opLabel || '').replace(/</g, '&lt;') + ' ' + (condition.valeur || '').replace(/</g, '&lt;');
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
View File
@@ -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); }
+2
View File
@@ -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>
+143 -20
View File
@@ -75,20 +75,117 @@
<details class="propGroup" open>
<summary>Mes assets</summary>
<div class="propGroupBody">
<div class="iconGallery personnageGallery">
{% for asset in user_assets %}
<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">
</button>
{% 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)">
</label>
<p class="help">Réutilisable dans tous tes projets — jamais visible par les autres comptes.</p>
<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_fond %}
<div class="userAssetTileWrap">
<button type="button" class="iconGalleryTile" title="{{ asset.original_name or 'Asset' }}"
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>
{% 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>
<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>
+34
View File
@@ -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")
+104
View File
@@ -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"]
+356 -6
View File
@@ -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():
+32
View File
@@ -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]
+56
View File
@@ -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(
+86
View File
@@ -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."""
+65
View File
@@ -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)
+262
View File
@@ -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
View File
@@ -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:
+341
View File
@@ -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