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
+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})