From 2347a70c452751bd9ed0c7dee508638023f0ebfb Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 11:20:58 +0200 Subject: [PATCH 01/23] =?UTF-8?q?Qu=C3=AAtes/collision=20:=20personnages?= =?UTF-8?q?=20nomm=C3=A9s,=20dialogues=20qui=20parlent,=20bo=C3=AEte=20de?= =?UTF-8?q?=20dialogue=20en=20jeu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "ℹ️ Informations" (propriétés d'un personnage) : nom éditable, réutilisé comme "qui parle" dans l'éditeur de dialogue de quête (menu déroulant, plus "Joueur" toujours disponible) — remplace l'ancien choix binaire joueur/pnj. db.sanitize_quest_dialogues accepte maintenant un nom libre. - Nouveau menu "🖥️ Interface" (palette d'objets) avec le premier widget : "💬 Boîte de dialogue" (kind="dialogue_box", screens/rendering/ dialogue_box_style.py) — position/taille comme tout objet de scène, panneau "🎨 Style" dédié (police, taille, épaisseur, couleur du texte, couleur header/body/footer). Rendu en
à 3 zones, jamais soumis à la collision ni à l'éditeur de collision (exclu partout : obstacles, liste des règles, payload). Rendu côté jeu dans .sceneUI, une couche FIXE au viewport (jamais .sceneWorld, qui défile avec la caméra). - static/js/play/dialogue-box-controller.js : fait le lien moteur entre l'action "quete" de l'éditeur de collision et ce widget — affiche la réplique en cours du dialogue correspondant au STATUT ACTUEL de la quête (gameData.quests, désormais exposé game-wide par full_game_payload.py), avance au clic sur "Suivant" (header = qui parle, body = texte, footer = bouton), se masque à la dernière réplique. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 4 +- db/quests/constants.py | 1 - db/quests/sanitize_quest_dialogues.py | 21 ++- routes/scenes/__init__.py | 2 + routes/scenes/scene_edit_view.py | 14 +- .../scenes/scene_object_dialogue_box_style.py | 14 ++ routes/scenes/scene_object_name.py | 12 ++ screens/__init__.py | 4 + screens/payload/full_game_payload.py | 17 +++ screens/rendering/dialogue_box_style.py | 50 +++++++ screens/rendering/personnage_names.py | 19 +++ screens/scenes/add_scene_object.py | 8 +- screens/scenes/render_scene_object.py | 26 ++++ .../set_scene_object_dialogue_box_style.py | 22 +++ screens/scenes/set_scene_object_name.py | 18 +++ .../collision-rules-controller.test.js | 11 +- .../__tests__/dialogue-box-controller.test.js | 98 ++++++++++++ static/js/play/collision-rules-controller.js | 12 +- static/js/play/dialogue-box-controller.js | 72 +++++++++ static/js/play/personnage-controller.js | 2 +- static/js/quests/quest-editor.js | 55 +++++-- static/js/scenes/scene-editor.js | 41 +++++ static/style.css | 31 +++- templates/play.html | 9 +- templates/scene_edit.html | 100 ++++++++++++- tests/test_dialogue_box.py | 140 ++++++++++++++++++ tests/test_quests.py | 12 +- 27 files changed, 774 insertions(+), 41 deletions(-) create mode 100644 routes/scenes/scene_object_dialogue_box_style.py create mode 100644 routes/scenes/scene_object_name.py create mode 100644 screens/rendering/dialogue_box_style.py create mode 100644 screens/rendering/personnage_names.py create mode 100644 screens/scenes/set_scene_object_dialogue_box_style.py create mode 100644 screens/scenes/set_scene_object_name.py create mode 100644 static/js/play/__tests__/dialogue-box-controller.test.js create mode 100644 static/js/play/dialogue-box-controller.js create mode 100644 tests/test_dialogue_box.py diff --git a/db/__init__.py b/db/__init__.py index 9d49d31f..35b2eb84 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -79,7 +79,7 @@ from .custom_events.create_custom_event import create_custom_event from .custom_events.update_custom_event import update_custom_event from .quests.constants import ( - QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES, + QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, ) from .quests.list_quests import list_quests from .quests.get_quest import get_quest @@ -106,7 +106,7 @@ __all__ = [ "update_global_variable_value", "update_global_variable", "delete_global_variable", "delete_global_variable_by_id", "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", - "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES", + "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "list_quests", "get_quest", "create_quest", "update_quest", "delete_quest", "set_quest_dialogues", "sanitize_quest_dialogues", ] diff --git a/db/quests/constants.py b/db/quests/constants.py index ca54a481..d01ef3fe 100644 --- a/db/quests/constants.py +++ b/db/quests/constants.py @@ -2,4 +2,3 @@ QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"] QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"} QUEST_RESULT_CHOICES = ["reussi", "echoue"] QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"} -QUEST_SPEAKER_CHOICES = ["joueur", "pnj"] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index dfaef61d..9f1aa16b 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -1,13 +1,20 @@ -from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES +from .constants import QUEST_STATUS_CHOICES _MAX_LINES_PER_COLUMN = 200 +_MAX_SPEAKER_LENGTH = 60 def sanitize_quest_dialogues(raw_dialogues): - """Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne - par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que - resolve_collision_rules.sanitize_collision_rules : rejette tout - élément invalide plutôt que de lever, ne garde jamais un texte vide.""" + """Une réplique = {speaker: '', text: '...'} — le + nom vient du champ "ℹ️ Informations" d'un objet de scène kind= + "personnage" (voir screens/rendering/personnage_names.py), ou + "Joueur" (toujours proposé, voir static/js/quests/quest-editor.js) : + texte libre côté serveur (jamais un enum fixe — n'importe quel nom + donné à un personnage doit être accepté), juste borné en longueur. + Une colonne par statut de quête (voir QUEST_STATUS_CHOICES), même + esprit que resolve_collision_rules.sanitize_collision_rules : rejette + tout élément invalide plutôt que de lever, ne garde jamais un texte + ou un nom vide.""" result = {status: [] for status in QUEST_STATUS_CHOICES} if not isinstance(raw_dialogues, dict): return result @@ -21,10 +28,10 @@ def sanitize_quest_dialogues(raw_dialogues): continue speaker = raw_line.get("speaker") text = raw_line.get("text") - if speaker not in QUEST_SPEAKER_CHOICES: + if not isinstance(speaker, str) or not speaker.strip(): continue if not isinstance(text, str) or not text.strip(): continue - cleaned.append({"speaker": speaker, "text": text.strip()}) + cleaned.append({"speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}) result[status] = cleaned return result diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index 8531ff69..f721b74a 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -7,4 +7,6 @@ from . import ( scene_object_role, scene_object_collision, scene_object_collision_rules, + scene_object_name, + scene_object_dialogue_box_style, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 75d1fa0d..c05f004c 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -26,13 +26,17 @@ def render_scene_edit(slug, screen_id, game): personnage_commands = None personnage_role = None collision_settings = None + dialogue_box_style = None selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None if selected_obj: - collision_settings = screens.resolve_collision_settings(selected_obj) + if selected_obj["kind"] != "dialogue_box": + collision_settings = screens.resolve_collision_settings(selected_obj) if selected_obj["kind"] == "personnage": personnage_data = screens.resolve_personnage_data(selected_obj) personnage_commands = screens.resolve_personnage_commands(selected_obj) personnage_role = screens.resolve_personnage_role(selected_obj) + elif selected_obj["kind"] == "dialogue_box": + dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj) _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} object_labels = { @@ -52,7 +56,7 @@ def render_scene_edit(slug, screen_id, game): # SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages). collision_rule_objects = [] for o in objects: - if o["kind"] == "fond": + if o["kind"] in ("fond", "dialogue_box"): continue role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None if role == "joueur": @@ -95,6 +99,8 @@ def render_scene_edit(slug, screen_id, game): personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE, personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS, collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS, + dialogue_box_style=dialogue_box_style or screens.DEFAULT_DIALOGUE_BOX_STYLE, + dialogue_box_font_weight_choices=screens.FONT_WEIGHT_CHOICES, sprite_library=sprite_library, sprite_library_families=sprite_library_families, sprite_library_json=json.dumps(sprite_library), background_library=background_library, @@ -134,4 +140,8 @@ def render_scene_edit(slug, screen_id, game): # existante, sans attendre l'ouverture de l'assistant "+ Action" # (seul moment où questPickerHtml irait la chercher sinon). quests_json=json.dumps(db.list_quests(slug)), + # "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations", + # static/js/quests/quest-editor.js) : les noms donnés aux + # personnages, toutes scènes du jeu confondues. + personnage_names_json=json.dumps(screens.list_personnage_names(slug)), ) diff --git a/routes/scenes/scene_object_dialogue_box_style.py b/routes/scenes/scene_object_dialogue_box_style.py new file mode 100644 index 00000000..41000402 --- /dev/null +++ b/routes/scenes/scene_object_dialogue_box_style.py @@ -0,0 +1,14 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//dialogue-box-style", methods=["POST"]) +def scene_object_dialogue_box_style(slug, object_id): + body = request.get_json(silent=True) or {} + style = screens.set_scene_object_dialogue_box_style(slug, object_id, body.get("style")) + if style is None: + return jsonify({"error": "objet introuvable"}), 404 + return jsonify({"style": style}) diff --git a/routes/scenes/scene_object_name.py b/routes/scenes/scene_object_name.py new file mode 100644 index 00000000..0442af05 --- /dev/null +++ b/routes/scenes/scene_object_name.py @@ -0,0 +1,12 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//name", methods=["POST"]) +def scene_object_name(slug, object_id): + data = request.get_json(force=True) + screens.set_scene_object_name(slug, object_id, data.get("name")) + return jsonify({"ok": True}) diff --git a/screens/__init__.py b/screens/__init__.py index 169c69c6..e98a027e 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -88,6 +88,8 @@ from .rendering.collision_rules import ( ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX, ) from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands +from .rendering.personnage_names import list_personnage_names +from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES from .screens_repo.ensure_schema import ensure_schema from .screens_repo.list_screens import list_screens @@ -125,6 +127,8 @@ from .scenes.set_scene_object_commands import set_scene_object_commands from .scenes.set_scene_object_role import set_scene_object_role from .scenes.set_scene_object_collision import set_scene_object_collision from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules +from .scenes.set_scene_object_name import set_scene_object_name +from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style from .scenes.render_scene_object import render_scene_object from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES from .elements.element_label import element_label diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 14930655..534fa6a0 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -12,6 +12,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.dialogue_box_style import resolve_dialogue_box_style from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -57,6 +58,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): if o["kind"] == "personnage": o["personnage_commandes"] = resolve_personnage_commands(o) o["personnage_role"] = resolve_personnage_role(o) + if o["kind"] == "dialogue_box": + # Widget d'interface (voir "🖥️ Interface", + # screens/rendering/dialogue_box_style.py) : ni + # collision, ni cible/source de règle — juste son + # style, lu par static/js/play/dialogue-box-controller.js. + o["dialogue_box_style"] = resolve_dialogue_box_style(o) + continue # Boîte de collision (voir static/js/play/conditions.js:: # elementsOverlap) : posée sur TOUT objet de scène, pas # seulement "personnage" — un décor/fond peut aussi servir @@ -198,9 +206,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # ci-dessus sont déjà indexés par id, jamais par nom. definition_names = {str(d["id"]): d["name"] for d in definitions} + # Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais + # rattachées à un écran comme "elements" ci-dessus — nécessaires ici + # pour que static/js/play/dialogue-box-controller.js puisse retrouver, + # à l'exécution d'une action "quete" (collision-rules-controller.js), + # le dialogue de la colonne correspondant au STATUT ACTUEL de la + # quête (nouvelle/en_cours/terminee) sans aller-retour serveur. + quests = db.list_quests(slug) + return { "screens": payload_screens, "flows": flows, "animations": animations, "element_types": element_types, "data": data, "fields_meta": fields_meta, "variables": variables, "personnage_animations": personnage_animations, "definition_names": definition_names, "element_type_templates": element_type_templates, + "quests": quests, } diff --git a/screens/rendering/dialogue_box_style.py b/screens/rendering/dialogue_box_style.py new file mode 100644 index 00000000..40aa6059 --- /dev/null +++ b/screens/rendering/dialogue_box_style.py @@ -0,0 +1,50 @@ +import json + +# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface", +# templates/scene_edit.html, screens/scenes/add_scene_object.py kind= +# "dialogue_box") : affiche AUTOMATIQUEMENT, en jeu, la réplique en cours +# d'une quête déclenchée par une règle de collision "quete" (voir +# static/js/play/dialogue-box-controller.js) — header (nom du +# personnage qui parle), corps (texte), pied (bouton "Suivant"), +# entièrement stylisable par l'auteur. +DEFAULT_DIALOGUE_BOX_STYLE = { + "font_family": "inherit", + "font_size": 14, + "font_weight": "normal", + "text_color": "#ffffff", + "header_bg": "#2f6fed", + "body_bg": "#1a1d24", + "footer_bg": "#11141b", +} + +FONT_WEIGHT_CHOICES = ["normal", "bold"] + + +def sanitize_dialogue_box_style(raw_style): + style = dict(DEFAULT_DIALOGUE_BOX_STYLE) + if not isinstance(raw_style, dict): + return style + if isinstance(raw_style.get("font_family"), str) and raw_style["font_family"].strip(): + style["font_family"] = raw_style["font_family"].strip()[:60] + try: + style["font_size"] = max(8, min(48, int(raw_style.get("font_size")))) + except (TypeError, ValueError): + pass + if raw_style.get("font_weight") in FONT_WEIGHT_CHOICES: + style["font_weight"] = raw_style["font_weight"] + for key in ("text_color", "header_bg", "body_bg", "footer_bg"): + value = raw_style.get(key) + if isinstance(value, str) and value.strip(): + style[key] = value.strip()[:20] + return style + + +def resolve_dialogue_box_style(obj): + raw = (obj.get("attributes") or {}).get("_dialogue_box_style") + if not raw: + return dict(DEFAULT_DIALOGUE_BOX_STYLE) + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return dict(DEFAULT_DIALOGUE_BOX_STYLE) + return sanitize_dialogue_box_style(parsed) diff --git a/screens/rendering/personnage_names.py b/screens/rendering/personnage_names.py new file mode 100644 index 00000000..ed88fb01 --- /dev/null +++ b/screens/rendering/personnage_names.py @@ -0,0 +1,19 @@ +from ..screens_repo.list_screens import list_screens +from ..scenes.list_scene_objects import list_scene_objects + + +def list_personnage_names(slug): + """Noms distincts de tous les personnages nommés (voir "ℹ️ + Informations", templates/scene_edit.html), toutes scènes 2D du jeu + confondues — une quête (voir db/quests/) est GAME-WIDE, son dialogue + peut donc mettre en scène un PNJ posé sur une autre scène que celle + où la règle de collision se déclenche. Ordre alphabétique, jamais de + doublon.""" + names = set() + for s in list_screens(slug): + if s["kind"] != "jeu_2d": + continue + for o in list_scene_objects(slug, s["id"]): + if o["kind"] == "personnage" and o.get("name"): + names.add(o["name"]) + return sorted(names) diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index 335a4ab9..abf56f16 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -5,7 +5,11 @@ import db from .ensure_scene_schema import ensure_scene_schema _DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"} -_KINDS = {"personnage", "decor", "fond"} +_KINDS = {"personnage", "decor", "fond", "dialogue_box"} +# Widget d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) : +# assez grand pour lire une réplique sans redimensionner, jamais confondu +# visuellement avec un personnage/décor (128×128 par défaut). +_DIALOGUE_BOX_DEFAULT_SIZE = (320, 150) def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None): @@ -40,6 +44,8 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba if background: attributes["src"] = background["url"] width, height = background["width"], background["height"] + elif kind == "dialogue_box": + width, height = _DIALOGUE_BOX_DEFAULT_SIZE conn = db.connect(slug) if kind == "fond": z_index = -1000 diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index 1784cedc..a895d685 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -1,6 +1,7 @@ import html as html_lib from ..rendering.personnage_data import resolve_personnage_idle_frame +from ..rendering.dialogue_box_style import resolve_dialogue_box_style def render_scene_object(obj): @@ -39,6 +40,31 @@ def render_scene_object(obj): # remplirait quand même son cadre (width/height:100%, voir la règle # `.canvasElementInner > img` dans scene_edit.html) mais DÉFORMÉE, # rendant le personnage minuscule/écrasé dans sa boîte de sélection. + if obj["kind"] == "dialogue_box": + # Widget d'interface (voir "🖥️ Interface" + screens/rendering/ + # dialogue_box_style.py) : un
à 3 zones, pas un — voir + # static/js/play/dialogue-box-controller.js pour comment header/ + # body/footer sont remplis EN JEU (data-dialogue-role sert de + # sélecteur), masqué par défaut au jeu (voir static/style.css, + # `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de + # collision "quete" ait un dialogue à afficher. Toujours VISIBLE + # dans l'éditeur (avec un texte d'exemple) pour pouvoir le + # positionner/styliser. + style = resolve_dialogue_box_style(obj) + box_style = ( + f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " + f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; " + f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; " + f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};" + ) + return ( + f'
' + f'
Nom du personnage
' + f'
Le texte du dialogue s\'affiche ici.
' + f'
' + f'' + f'
' + ) if obj["kind"] == "personnage": src = resolve_personnage_idle_frame(obj) src_attr = f' src="{html_lib.escape(src)}"' if src else "" diff --git a/screens/scenes/set_scene_object_dialogue_box_style.py b/screens/scenes/set_scene_object_dialogue_box_style.py new file mode 100644 index 00000000..69e8f59d --- /dev/null +++ b/screens/scenes/set_scene_object_dialogue_box_style.py @@ -0,0 +1,22 @@ +import json + +import db + +from ..rendering.dialogue_box_style import sanitize_dialogue_box_style +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_dialogue_box_style(slug, object_id, raw_style): + ensure_scene_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone() + if not row: + conn.close() + return None + attributes = json.loads(row["attributes"] or "{}") + style = sanitize_dialogue_box_style(raw_style) + attributes["_dialogue_box_style"] = json.dumps(style) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() + return style diff --git a/screens/scenes/set_scene_object_name.py b/screens/scenes/set_scene_object_name.py new file mode 100644 index 00000000..9878c849 --- /dev/null +++ b/screens/scenes/set_scene_object_name.py @@ -0,0 +1,18 @@ +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_name(slug, object_id, name): + """Enregistre le nom d'un objet de scène (colonne `name`, voir + ensure_scene_schema.py — déjà utilisée comme LIBELLÉ d'affichage + partout ailleurs, mais jamais éditable avant le panneau "ℹ️ + Informations", templates/scene_edit.html). Pour un personnage, ce nom + est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue + de quête (voir screens/rendering/personnage_names.py, static/js/ + quests/quest-editor.js).""" + ensure_scene_schema(slug) + conn = db.connect(slug) + conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id)) + conn.commit() + conn.close() diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js index 4ec3f1a2..b3d64dc1 100644 --- a/static/js/play/__tests__/collision-rules-controller.test.js +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -71,10 +71,13 @@ test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEve assert.deepEqual(calls, [42]); }); -test('forgeRunCollisionRuleAction — "quete" mémorise l\'id dans window.forgeQuestLog (pas d\'éditeur de quête pour le moment)', () => { - window.forgeQuestLog = []; - forgeRunCollisionRuleAction({ type: 'quete', quete_id: 'q-1' }, fakeEl(), { id: 1 }); - assert.deepEqual(window.forgeQuestLog, ['q-1']); +test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => { + const calls = []; + const previousShow = global.forgeShowDialogueBox; + global.forgeShowDialogueBox = id => calls.push(id); + forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 }); + global.forgeShowDialogueBox = previousShow; + assert.deepEqual(calls, [42]); }); test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => { diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js new file mode 100644 index 00000000..ef494c94 --- /dev/null +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -0,0 +1,98 @@ +// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir +// static/js/play/dialogue-box-controller.js) : affiche la réplique en +// cours du dialogue d'une quête (gameData.quests) correspondant à son +// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +global.gameData = {}; + +const { + forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, +} = require('../dialogue-box-controller.js'); + +function fakeWidget(objectId) { + const roles = { + header: { textContent: '' }, + body: { textContent: '' }, + }; + const classes = new Set(); + return { + getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null), + classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + querySelector: sel => { + if (sel === '[data-dialogue-role="header"]') return roles.header; + if (sel === '[data-dialogue-role="body"]') return roles.body; + return null; + }, + _roles: roles, _classes: classes, + }; +} + +test.beforeEach(() => { + global.window.currentScreenId = 1; + global.gameData = { + quests: [ + { id: 5, status: 'nouvelle', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } }, + { id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } }, + ], + }; +}); + +test('forgeQuestById retrouve une quête par id', () => { + assert.equal(forgeQuestById(5).id, 5); + assert.equal(forgeQuestById(999), undefined); +}); + +test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => { + const previousDoc = global.document; + global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } }; + try { + assert.doesNotThrow(() => forgeShowDialogueBox(999)); + } finally { global.document = previousDoc; } +}); + +test('forgeShowDialogueBox — quête sans réplique pour son statut actuel : aucun effet', () => { + const previousDoc = global.document; + global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } }; + try { + assert.doesNotThrow(() => forgeShowDialogueBox(6)); // "en_cours" -> dialogues.en_cours est vide + } finally { global.document = previousDoc; } +}); + +test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet', () => { + const previousDoc = global.document; + global.document = { querySelector: () => null }; + try { + assert.doesNotThrow(() => forgeShowDialogueBox(5)); + } finally { global.document = previousDoc; } +}); + +test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => { + const widget = fakeWidget(42); + const previousDoc = global.document; + global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; + try { + forgeShowDialogueBox(5); + assert.equal(widget._roles.header.textContent, 'Le Sorcier'); + assert.equal(widget._roles.body.textContent, 'Bonjour !'); + assert.equal(widget._classes.has('is-active'), true); + + forgeDialogueBoxAdvance(42); + assert.equal(widget._roles.header.textContent, 'Joueur'); + assert.equal(widget._roles.body.textContent, 'Salut.'); + assert.equal(widget._classes.has('is-active'), true); // encore une réplique visible + + forgeDialogueBoxAdvance(42); // dernière réplique consommée -> se masque + assert.equal(widget._classes.has('is-active'), false); + } finally { global.document = previousDoc; } +}); + +test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => { + const previousDoc = global.document; + global.document = { querySelector: () => null }; + try { + assert.doesNotThrow(() => forgeDialogueBoxAdvance(999)); + } finally { global.document = previousDoc; } +}); diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js index 277b4b23..c5c1c1f8 100644 --- a/static/js/play/collision-rules-controller.js +++ b/static/js/play/collision-rules-controller.js @@ -80,10 +80,12 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) { return; } if (action.type === 'quete') { - // Éditeur de quête pas encore construit (voir plan) — mémorise - // l'intention pour un futur système de quêtes plutôt que de la perdre. - window.forgeQuestLog = window.forgeQuestLog || []; - window.forgeQuestLog.push(action.quete_id); + // Affiche la réplique en cours du dialogue de cette quête, selon son + // statut actuel, dans le widget "💬 Boîte de dialogue" posé sur + // l'écran (voir static/js/play/dialogue-box-controller.js) — sans + // effet si aucun widget n'est posé ou si la quête n'a aucune + // réplique pour son statut, comme le reste de ce moteur. + forgeShowDialogueBox(action.quete_id); return; } } @@ -185,4 +187,6 @@ if (typeof module !== 'undefined' && module.exports) { const personnageMod = require('./personnage-controller.js'); global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists; global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8; + const dialogueBoxMod = require('./dialogue-box-controller.js'); + global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox; } diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js new file mode 100644 index 00000000..23e49fb2 --- /dev/null +++ b/static/js/play/dialogue-box-controller.js @@ -0,0 +1,72 @@ +// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ---------- +// Fait le lien entre l'éditeur de collision (action "quete", voir +// static/js/play/collision-rules-controller.js) et le widget d'interface +// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, +// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action +// "quete", affiche la réplique en cours du dialogue correspondant au +// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py), +// avance d'une réplique à chaque clic sur "Suivant", se masque à la fin. +// Silencieux (aucun effet) si la quête est introuvable, n'a aucune +// réplique pour son statut, ou si l'auteur n'a posé aucun widget sur +// l'écran — même esprit que le reste du moteur (jamais planter). + +var forgeDialogueBoxState = {}; + +function forgeQuestById(questId) { + return (gameData.quests || []).find(function (q) { return q.id === questId; }); +} + +function forgeDialogueBoxWidgetOnCurrentScreen() { + return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget'); +} + +function forgeDialogueBoxWidgetById(objectId) { + return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]'); +} + +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; +} + +// Appelée par l'action "quete" (collision-rules-controller.js) — trouve +// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois, +// comme le reste du moteur 2D). +function forgeShowDialogueBox(questId) { + var quest = forgeQuestById(questId); + if (!quest) return; + var lines = (quest.dialogues && quest.dialogues[quest.status]) || []; + if (!lines.length) return; + var widget = forgeDialogueBoxWidgetOnCurrentScreen(); + if (!widget) return; + var objectId = widget.getAttribute('data-object-id'); + forgeDialogueBoxState[objectId] = { lines: lines, index: 0 }; + forgeRenderDialogueBoxLine(widget, lines[0]); + widget.classList.add('is-active'); +} + +// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir +// render_scene_object.py, onclick="forgeDialogueBoxAdvance()"). +function forgeDialogueBoxAdvance(objectId) { + var state = forgeDialogueBoxState[objectId]; + var widget = forgeDialogueBoxWidgetById(objectId); + if (!state || !widget) return; + state.index += 1; + if (state.index >= state.lines.length) { + widget.classList.remove('is-active'); + delete forgeDialogueBoxState[objectId]; + return; + } + forgeRenderDialogueBoxLine(widget, state.lines[state.index]); +} + +// static/js/play/__tests__/ (node:test) — même garde que les autres +// contrôleurs de ce dossier. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, + forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, + }; +} diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 33e2746a..d4112e38 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) { // se déplace lui-même. function forgeSolidObstacles(screenData, movingId) { return (screenData.elements || []).filter(function (o) { - return o.id !== movingId && o.kind !== 'fond' && !(o.collision && o.collision.enabled === false); + return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false); }); } diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index 452def22..95d3d39e 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -27,6 +27,20 @@ var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; // besoin (collisionRuleActionSummary). var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; +// Noms de personnages nommés (voir "ℹ️ Informations", templates/ +// scene_edit.html) — proposés comme "qui parle" pour une réplique, en +// plus de "Joueur" (toujours disponible, jamais un objet de scène). +var PERSONNAGE_NAMES = (typeof PERSONNAGE_NAMES_JSON !== 'undefined') ? PERSONNAGE_NAMES_JSON : []; + +// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/ +// .questBubble-pnj, static/style.css) même si le nom affiché est +// n'importe quel personnage nommé — "Joueur" à droite en vert, n'importe +// quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles +// du pnj et du joueur sont d'une couleur différente"). +function questBubbleKind(speaker) { + return speaker === 'Joueur' ? 'joueur' : 'pnj'; +} + function questById(questId) { return QUESTS.find(function (q) { return q.id === questId; }); } @@ -178,13 +192,26 @@ function renderQuestFieldsModalBody(questId) { '
'; } +// Options du ' + speakerSelect + '' + '' + @@ -292,22 +319,30 @@ function questSaveDialogues(quest) { .then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; }); } +// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de +// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations") +// — juste un défaut de départ, l'auteur choisit ensuite librement dans +// le menu déroulant de la réplique. +function questNextDefaultSpeaker(lastSpeaker) { + if (lastSpeaker === 'Joueur') return PERSONNAGE_NAMES[0] || 'PNJ'; + return 'Joueur'; +} + function questAddBubble(status) { var quest = questById(_currentQuestModalId()); if (!quest) return; quest.dialogues = quest.dialogues || {}; var lines = quest.dialogues[status] || (quest.dialogues[status] = []); - var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'pnj'; - lines.push({ speaker: lastSpeaker === 'pnj' ? 'joueur' : 'pnj', text: '' }); + var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ'; + lines.push({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' }); renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } -function questToggleSpeaker(status, index) { +function questSetBubbleSpeaker(status, index, speaker) { var quest = questById(_currentQuestModalId()); if (!quest) return; - var line = quest.dialogues[status][index]; - line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur'; + quest.dialogues[status][index].speaker = speaker; renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index beea419c..0ea3c00b 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -361,6 +361,47 @@ function addPersonnageCommandExtraRow() { container.appendChild(personnageCommandExtraRowEl(objectId, '', '')); } +// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un +// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue de +// quête (voir static/js/quests/quest-editor.js). +function saveSceneObjectName(objectId, name) { + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name }), + }); +} + +// "🎨 Style" du widget "💬 Boîte de dialogue" (voir screens/rendering/ +// dialogue_box_style.py) — envoie TOUJOURS les 7 champs ensemble (même +// convention "replace-all" que saveSceneObjectCollision), jamais un seul +// champ isolé. +function saveDialogueBoxStyle(objectId) { + var style = { + font_family: document.getElementById('dbox-font-family').value, + font_size: Number(document.getElementById('dbox-font-size').value), + font_weight: document.getElementById('dbox-font-weight').value, + text_color: document.getElementById('dbox-text-color').value, + header_bg: document.getElementById('dbox-header-bg').value, + body_bg: document.getElementById('dbox-body-bg').value, + footer_bg: document.getElementById('dbox-footer-bg').value, + }; + var el = document.getElementById('el-' + objectId); + var widget = el && el.querySelector('.dialogueBoxWidget'); + if (widget) { + widget.style.fontFamily = style.font_family; + widget.style.fontSize = style.font_size + 'px'; + widget.style.fontWeight = style.font_weight; + widget.style.color = style.text_color; + var header = widget.querySelector('.dialogueBoxHeader'); if (header) header.style.background = style.header_bg; + var b = widget.querySelector('.dialogueBoxBody'); if (b) b.style.background = style.body_bg; + var footer = widget.querySelector('.dialogueBoxFooter'); if (footer) footer.style.background = style.footer_bg; + } + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/dialogue-box-style', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ style: style }), + }); +} + (function () { var body = document.getElementById('personnageCommandsBody'); if (!body) return; diff --git a/static/style.css b/static/style.css index 1c7c6838..e5a75d14 100644 --- a/static/style.css +++ b/static/style.css @@ -634,6 +634,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d comporte donc exactement comme avant. */ .playScreen.playScene{ overflow:hidden; } .sceneWorld{ position:relative; width:100%; height:100%; } +/* .sceneUI : couche d'interface FIXE au VIEWPORT (jamais dans + .sceneWorld, qui défile avec la caméra, voir static/js/play/ + personnage-controller.js) — le widget "💬 Boîte de dialogue" y vit, + voir templates/play.html. */ +.sceneUI{ position:absolute; inset:0; pointer-events:none; z-index:500; } + +/* Widget "💬 Boîte de dialogue" (voir screens/rendering/dialogue_box_style.py, + static/js/play/dialogue-box-controller.js) : header/body/footer, + toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/ + styliser) mais masqué par défaut EN JEU tant qu'aucune règle de + collision "quete" n'a de réplique à afficher. */ +.dialogueBoxWidget{ + display:flex; flex-direction:column; border-radius:10px; overflow:hidden; + box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto; +} +.playScreen .dialogueBoxWidget{ display:none; } +.playScreen .dialogueBoxWidget.is-active{ display:flex; } +.dialogueBoxHeader{ padding:8px 12px; font-weight:700; } +.dialogueBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; } +.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; } +.dialogueBoxNextBtn{ + background:rgba(255,255,255,.15); color:inherit; border:1px solid rgba(255,255,255,.3); + border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer; +} +.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); } /* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur sa propre balise (voir applySelectionHighlight) — seul moyen de la voir @@ -804,7 +829,11 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d } .questBubble-pnj{ background:linear-gradient(135deg, #2f6fed, #1e4fb0); border-bottom-left-radius:4px; } .questBubble-joueur{ background:linear-gradient(135deg, #2ecc71, #1e9e57); border-bottom-right-radius:4px; } -.questBubbleSpeaker{ font-size:10.5px; font-weight:700; color:rgba(255,255,255,.85); cursor:pointer; } +.questBubbleSpeaker{ + font-size:10.5px; font-weight:700; color:rgba(255,255,255,.85); cursor:pointer; + background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; +} +.questBubbleSpeaker option{ color:#111; } .questBubbleText{ resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px; font-family:inherit; padding:0; min-height:1.4em; diff --git a/templates/play.html b/templates/play.html index 3c1875ee..63c58335 100644 --- a/templates/play.html +++ b/templates/play.html @@ -84,7 +84,13 @@ uniquement si un objet kind="fond" plus grand que la scène est posé (caméra qui suit le personnage). -->
- {% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %} + {% for obj in s.elements if obj.kind != 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %} +
+ +
+ {% for obj in s.elements if obj.kind == 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %}
{% else %} @@ -155,6 +161,7 @@ + diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 575aef86..d97be914 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -15,6 +15,9 @@ #canvas (son ancêtre positionné le plus proche), en double du décalage déjà appliqué par .canvasElement. */ .canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } + /* Même correctif que ci-dessus, pour le widget "💬 Boîte de dialogue" + (screens/scenes/render_scene_object.py, un
, pas un ). */ + .canvasElementInner > .dialogueBoxWidget{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } {% endblock %} {% block content %} @@ -87,6 +90,20 @@
{% endif %} + +
+ 🖥️ Interface +
+ +

Affiche automatiquement le dialogue de la quête en cours (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posée.

+
+
@@ -107,7 +124,7 @@ onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
{{ o.rendered_html|safe }}
- {% if o.id == selected_id and o.kind != 'fond' %} + {% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box') %} +
+ ℹ️ Informations +
+
+ +
+
+
+
+ {% endif %} +
Position & taille (pixels)
@@ -279,7 +312,65 @@
- {% if obj.kind != 'fond' %} + {% if obj.kind == 'dialogue_box' %} + +
+ 🎨 Style +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %} + + {% if obj.kind not in ('fond', 'dialogue_box') %} + {% if obj.kind != 'dialogue_box' %} +
ℹ️ Informations
- +
@@ -556,8 +559,9 @@ // "+ Action" ouvre directement cet éditeur (renderQuestPickerStep). var QUESTS_JSON = {{ quests_json|safe }}; // "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" + - // static/js/quests/quest-editor.js::questSpeakerOptions). - var PERSONNAGE_NAMES_JSON = {{ personnage_names_json|safe }}; + // static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe + // quel objet de scène nommé, pas seulement un personnage. + var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }}; diff --git a/tests/test_dialogue_box.py b/tests/test_dialogue_box.py index f4ba2333..056e5049 100644 --- a/tests/test_dialogue_box.py +++ b/tests/test_dialogue_box.py @@ -5,7 +5,7 @@ cours d'une quête déclenchée par une règle de collision "quete" (voir static/js/play/dialogue-box-controller.js) — header/body/footer stylisables. Et le nom d'un personnage ("ℹ️ Informations", screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans -l'éditeur de dialogue de quête (screens/rendering/personnage_names.py).""" +l'éditeur de dialogue de quête (screens/rendering/scene_object_names.py).""" import db import screens @@ -125,8 +125,11 @@ def test_scene_object_name_route_persists(client, tmp_game_slug_cleanup): assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier" -def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_game_slug_cleanup): - slug = tmp_game_slug_cleanup(db.create_game("pytest_personnage_names")) +def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_kind(tmp_game_slug_cleanup): + """"il peut sélectionner le nom du personnage qui parle" -> étendu à + N'IMPORTE QUEL objet de scène nommé (pas seulement un personnage), + voir screens/rendering/scene_object_names.py.""" + slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_object_names")) scene_a = screens.create_screen(slug, "Scène A", kind="jeu_2d") scene_b = screens.create_screen(slug, "Scène B", kind="jeu_2d") zed_id = screens.add_scene_object(slug, scene_a, kind="personnage") @@ -134,7 +137,9 @@ def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_gam anna_id = screens.add_scene_object(slug, scene_b, kind="personnage") screens.set_scene_object_name(slug, anna_id, "Anna") unnamed_id = screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré - decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # pas un personnage, ignoré - screens.set_scene_object_name(slug, decor_id, "Un décor nommé") + decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # nommé -> INCLUS (pas réservé au personnage) + screens.set_scene_object_name(slug, decor_id, "Un coffre") + dialogue_box_id = screens.add_scene_object(slug, scene_a, kind="dialogue_box") # widget d'UI, jamais un "qui parle" + screens.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur") - assert screens.list_personnage_names(slug) == ["Anna", "Zed le forgeron"] + assert screens.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"] diff --git a/tests/test_quests.py b/tests/test_quests.py index 60d120c2..a5045ad3 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -75,7 +75,7 @@ def test_delete_quest_removes_it(tmp_game_slug_cleanup): def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status(): """speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations", - screens/rendering/personnage_names.py) — "Le Sorcier" est donc un nom + screens/rendering/scene_object_names.py) — "Le Sorcier" est donc un nom valide comme n'importe quel autre, seul un speaker/texte VIDE ou un élément mal formé est rejeté.""" raw = { From 077af2234db3eda58658b9c3f6346f2b3ee138c2 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 12:08:41 +0200 Subject: [PATCH 03/23] =?UTF-8?q?Fix=20"+=20R=C3=A9plique"=20qui=20rempla?= =?UTF-8?q?=C3=A7ait=20la=20bulle=20au=20lieu=20d'en=20ajouter=20une?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug : questSaveDialogues() adoptait la réponse du serveur comme nouvel état local après chaque enregistrement. Une bulle fraîchement ajoutée a un texte encore vide (rien tapé) — sanitize_quest_dialogues.py la rejette légitimement (jamais de réplique sans texte persistée) — donc la réponse renvoyait un tableau amputé de cette bulle, que le client adoptait aveuglément, effaçant la bulle en cours d'écriture. Le clic suivant sur "+ Réplique" repartait donc du même état qu'avant, semblant "remplacer" la bulle plutôt que d'en ajouter une seconde. Le client ne resynchronise plus jamais son état depuis la réponse d'enregistrement — il est déjà la seule source de vérité pendant l'édition. Colonnes de dialogue : occupent maintenant toute la hauteur disponible de la modale plein écran et défilent chacune indépendamment (titre et bouton "+ Réplique" restent fixes), au lieu d'une hauteur minimale fixe. Co-Authored-By: Claude Sonnet 5 --- static/js/quests/quest-editor.js | 13 ++++++++++--- static/style.css | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index 8c28c345..a32b685e 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -321,12 +321,19 @@ function questUpdateField(questId, field, value) { // Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir // db/quests/set_quest_dialogues.py) — jamais une réplique isolée. +// N'ADOPTE JAMAIS la réponse du serveur comme nouvel état local (bug +// corrigé : sanitize_quest_dialogues.py rejette toute réplique au texte +// encore vide — juste ajoutée, pas encore tapée — donc chaque +// enregistrement renvoyait un tableau amputé de la bulle en cours +// d'écriture ; l'adopter effaçait silencieusement la bulle que l'auteur +// venait d'ajouter, et le clic suivant sur "+ Réplique" semblait la +// "remplacer" au lieu d'en ajouter une seconde). L'état local, construit +// par des mutations client explicites, est déjà la seule source de +// vérité pendant l'édition. function questSaveDialogues(quest) { fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }), - }) - .then(function (r) { return r.json(); }) - .then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; }); + }); } // Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de diff --git a/static/style.css b/static/style.css index b96649fa..e9a92e02 100644 --- a/static/style.css +++ b/static/style.css @@ -805,23 +805,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d plutôt qu'une boîte centrée, pour laisser toute la place aux 3 colonnes de répliques. */ .questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; } -.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow-y:auto; } +/* La modale entière est une colonne flex (jamais un scroll global, voir + overflow:hidden ici) : le header/la fiche restent fixes en haut, les 3 + colonnes de répliques se partagent TOUT le reste de la hauteur + (demandé explicitement) et défilent CHACUNE indépendamment (voir + .questDialogueColumnBubbles plus bas), jamais toute la page. */ +.questDialogueModal .box{ width:100%; height:100%; border-radius:0; overflow:hidden; display:flex; flex-direction:column; } .questDialogueModalContent{ width:100%; height:100%; max-width:100%; } -.questDialogueModalHeader{ margin-bottom:10px; } +#questDialogueModalBody{ display:flex; flex-direction:column; flex:1; min-height:0; } +.questDialogueModalHeader{ flex:0 0 auto; margin-bottom:10px; } .questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; } .questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; } .questField-wide{ grid-column:span 2; } -.questDialogueColumns{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; overflow-x:auto; } +.questDialogueColumns{ flex:1; min-height:0; display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; } .questDialogueColumn{ background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px; - display:flex; flex-direction:column; gap:8px; min-height:160px; + display:flex; flex-direction:column; gap:8px; height:100%; min-height:0; } -.questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; } +.questDialogueColumnTitle{ flex:0 0 auto; margin:0 0 2px; font-size:13px; text-align:center; } /* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un trait entre chacune (voir ::before sur .questBubbleRow ci-dessous) — - demandé explicitement plutôt qu'un alignement gauche/droite. */ -.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; padding-top:4px; } -.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; margin-top:4px; } + demandé explicitement plutôt qu'un alignement gauche/droite. Seule + CETTE zone défile (flex:1 + overflow-y:auto) — le titre de colonne et + le bouton "+ Réplique" restent fixes, en haut/en bas. */ +.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; padding-top:4px; } +.questDialogueAddBtn{ flex:0 0 auto; align-self:center; padding:6px 14px; font-size:12px; margin-top:4px; } .questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; } .questBubbleRow:not(:first-child)::before{ From 5e17e42dfa25128780e8c976eca5093d0404f6f2 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 12:27:50 +0200 Subject: [PATCH 04/23] =?UTF-8?q?Test=20bout=20en=20bout=20:=20collision?= =?UTF-8?q?=20->=20qu=C3=AAte=20->=20bo=C3=AEte=20de=20dialogue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vérifie explicitement le lien moteur demandé : une règle de collision "quete" retrouve la quête, récupère les répliques de son statut actuel, les affiche dans l'ordre dans le widget "💬 Boîte de dialogue" posé sur la scène, avance au clic sur "Suivant", disparaît à la dernière réplique — déjà implémenté (collision-rules-controller.js + dialogue-box-controller.js), ce test couvre la CHAÎNE COMPLÈTE plutôt que chaque brique isolément. Co-Authored-By: Claude Sonnet 5 --- ...llision-quete-dialogue-integration.test.js | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 static/js/play/__tests__/collision-quete-dialogue-integration.test.js diff --git a/static/js/play/__tests__/collision-quete-dialogue-integration.test.js b/static/js/play/__tests__/collision-quete-dialogue-integration.test.js new file mode 100644 index 00000000..dc0ce522 --- /dev/null +++ b/static/js/play/__tests__/collision-quete-dialogue-integration.test.js @@ -0,0 +1,108 @@ +// Bout en bout : une règle de collision "quete" doit réellement faire +// apparaître/avancer/disparaître la boîte de dialogue posée sur la scène +// — pas seulement chaque brique testée isolément (voir +// collision-rules-controller.test.js et dialogue-box-controller.test.js). +// Reproduit le scénario signalé : un objet "Mr. Pinguin" avec une règle +// "à la collision -> déclencher la quête « test »", une quête au statut +// "nouvelle" avec 2 répliques, un widget "💬 Boîte de dialogue" posé sur +// la même scène. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +global.gameData = {}; +global.screensData = []; +global.heldKeys = new Set(); + +const actionsMod = require('../actions.js'); +Object.assign(global, actionsMod); +require('../personnage-controller.js'); +const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier + +const { forgeCollisionRuleTick } = require('../collision-rules-controller.js'); + +function fakeEl(style) { + const classes = new Set(); + const children = []; + return { + style: style, + classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + children, + appendChild(child) { children.push(child); }, + querySelector(sel) { + if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget); + if (sel === '[data-dialogue-role="header"]') return this._header; + if (sel === '[data-dialogue-role="body"]') return this._body; + return undefined; + }, + getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; }, + }; +} + +test('collision "quete" -> boîte de dialogue affiche la 1ère réplique, "Suivant" avance, se masque à la fin', () => { + const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' }; + const mrPinguin = { + id: 2, kind: 'personnage', + collision_rules: [{ trigger: 'collision', action: { type: 'quete', quete_id: 99 } }], + }; + const box = { id: 3, kind: 'dialogue_box' }; + + const playerEl = fakeEl({ left: '0px', top: '0px', width: '20px', height: '20px' }); + const pinguinEl = fakeEl({ left: '5px', top: '5px', width: '20px', height: '20px' }); // chevauche playerEl + const boxWidget = fakeEl({}); + boxWidget._isDialogueBoxWidget = true; + boxWidget._objectId = '3'; + boxWidget._header = { textContent: '' }; + boxWidget._body = { textContent: '' }; + + global.window.currentScreenId = 1; + global.gameData = { + personnage_animations: {}, + quests: [{ + id: 99, status: 'nouvelle', + dialogues: { + nouvelle: [ + { speaker: 'Mr. Pinguin', text: 'Bonjour !' }, + { speaker: 'Charlie', text: 'Salut !' }, + ], + en_cours: [], terminee: [], + }, + }], + }; + + const previousDoc = global.document; + global.document = { + querySelector(sel) { + // forgeDialogueBoxWidgetOnCurrentScreen() cherche + // '.playScreen[data-screen-id="1"] .dialogueBoxWidget' — à vérifier + // AVANT les sélecteurs [data-object-id="..."] ci-dessous (le "1" du + // numéro de scène apparaît aussi comme sous-chaîne de cette + // requête, source d'ambiguïté sinon). + if (sel.indexOf('.dialogueBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return boxWidget; + if (sel.indexOf('data-object-id="1"') !== -1) return playerEl; + if (sel.indexOf('data-object-id="2"') !== -1) return pinguinEl; + // forgeDialogueBoxWidgetById('3') — appelé par le clic "Suivant" + if (sel.indexOf('data-object-id="3"') !== -1) return boxWidget; + return null; + }, + }; + try { + const screenData = { elements: [player, mrPinguin, box] }; + + forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche la quête + assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); + assert.equal(boxWidget._body.textContent, 'Bonjour !'); + assert.equal(boxWidget.classList.contains('is-active'), true); + + forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas la quête + assert.equal(boxWidget._header.textContent, 'Mr. Pinguin'); // pas repartie de la 1ère réplique + + forgeDialogueBoxAdvance('3'); // clic "Suivant" (bouton du widget) + assert.equal(boxWidget._header.textContent, 'Charlie'); + assert.equal(boxWidget._body.textContent, 'Salut !'); + assert.equal(boxWidget.classList.contains('is-active'), true); + + forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> la modale disparaît + assert.equal(boxWidget.classList.contains('is-active'), false); + } finally { global.document = previousDoc; } +}); From 3a67ba622b63b031e8808e238650409c5b3b9b35 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 12:33:59 +0200 Subject: [PATCH 05/23] =?UTF-8?q?Fix=20"=C3=A0=20la=20collision"=20qui=20n?= =?UTF-8?q?e=20se=20d=C3=A9clenchait=20jamais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause réelle : un objet portant une règle de collision est presque toujours AUSSI un obstacle solide (collision activée par défaut, voir forgeWouldCollide, personnage-controller.js) — le déplacement du joueur est donc bloqué PILE au contact, sans jamais laisser les deux boîtes se chevaucher réellement. Le déclencheur "collision" testait un chevauchement STRICT (forgeShapesOverlap nu), qui n'était donc jamais atteint : la règle ne se déclenchait jamais en jeu réel, malgré un contact visible à l'écran. Le déclencheur teste maintenant la boîte de l'objet élargie d'une petite marge (3px) — assez pour détecter un simple contact — SANS toucher au blocage physique du déplacement (resté strict, inchangé). Co-Authored-By: Claude Sonnet 5 --- .../collision-rules-controller.test.js | 14 +++++++- static/js/play/collision-rules-controller.js | 36 +++++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js index b3d64dc1..114efed5 100644 --- a/static/js/play/__tests__/collision-rules-controller.test.js +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -49,7 +49,7 @@ function rect(left, top, width, height) { return { left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle' }; } -test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai seulement si les boîtes se chevauchent', () => { +test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai si les boîtes se chevauchent, faux si loin', () => { const objRect = rect(0, 0, 20, 20); const overlapping = rect(10, 10, 20, 20); const far = rect(200, 200, 20, 20); @@ -57,6 +57,18 @@ test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai seulement s assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, far), false); }); +test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai aussi pour un simple CONTACT (bords qui se touchent sans se chevaucher)', () => { + // Bug corrigé : cet objet est presque toujours aussi un obstacle + // SOLIDE (forgeWouldCollide, personnage-controller.js) — le joueur ne + // peut donc jamais dépasser un contact tangent, et un chevauchement + // STRICT n'était donc jamais atteint : "à la collision" ne se + // déclenchait jamais en jeu réel. objRect va de x=[20,40], playerRect + // juste à côté en x=[0,20] : bord à bord, AUCUN chevauchement géométrique. + const objRect = rect(20, 0, 20, 20); + const touchingPlayer = rect(0, 0, 20, 20); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, touchingPlayer), true); +}); + test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => { const objRect = rect(0, 0, 10, 10); // centre (5,5) const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10 diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js index c5c1c1f8..beba63a4 100644 --- a/static/js/play/collision-rules-controller.js +++ b/static/js/play/collision-rules-controller.js @@ -32,13 +32,35 @@ function forgeElementBoxRect(el, collision) { return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision); } -// "collision" : même chevauchement de boîtes que forgeWouldCollide -// (personnage-controller.js). "perimetre" : distance entre les CENTRES -// des deux boîtes <= rule.perimetre_px (pas entre les bords — plus simple -// et suffisant pour une portée approximative, cohérent avec le reste du -// moteur qui ne vise pas une physique précise). +// Une petite marge (px) appliquée SEULEMENT à la détection du +// déclencheur "collision" ci-dessous — jamais au blocage physique du +// déplacement (forgeWouldCollide, personnage-controller.js, qui reste +// strict). Bug corrigé : un objet est presque toujours aussi un +// OBSTACLE SOLIDE (collision activée par défaut) — le joueur ne peut +// donc jamais aller au-delà d'un contact tangent (bords qui se touchent +// SANS se chevaucher), et un chevauchement STRICT (forgeShapesOverlap +// nu) n'était donc jamais atteint : la règle "à la collision" ne se +// déclenchait jamais. Cette marge élargit la boîte testée pour que ce +// simple contact compte comme une collision. +var FORGE_COLLISION_RULE_TOUCH_MARGIN = 3; + +function forgeExpandRect(rect, margin) { + return { + left: rect.left - margin, top: rect.top - margin, + right: rect.right + margin, bottom: rect.bottom + margin, + width: rect.width + margin * 2, height: rect.height + margin * 2, + shape: rect.shape, + }; +} + +// "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour +// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN +// ci-dessus). "perimetre" : distance entre les CENTRES des deux boîtes +// <= rule.perimetre_px (pas entre les bords — plus simple et suffisant +// pour une portée approximative, cohérent avec le reste du moteur qui ne +// vise pas une physique précise). function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { - if (rule.trigger === 'collision') return forgeShapesOverlap(objRect, playerRect); + if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect); var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2; var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2; var dist = Math.hypot(ocx - pcx, ocy - pcy); @@ -179,7 +201,7 @@ if (typeof module !== 'undefined' && module.exports) { module.exports = { forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers, - forgeCollisionRuleStateKey, forgeElementBoxRect, + forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect, }; const conditionsMod = require('./conditions.js'); global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox; From 726775fe4ef82d96d309c5476358e45a2bb7ca43 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 12:42:17 +0200 Subject: [PATCH 06/23] =?UTF-8?q?Fix=20"=C3=A0=20la=20collision"=20?= =?UTF-8?q?=E2=80=94=20la=20marge=20de=203px=20restait=20insuffisante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forgeWouldCollide (personnage-controller.js) bloque le déplacement par PAS ENTIER (cmd.vitesse px/tick, 4 par défaut mais réglable) : le joueur peut donc rester bloqué jusqu'à PRESQUE un pas entier de distance de l'obstacle, pas seulement 1-3px — la marge de tolérance du correctif précédent (3px) restait insuffisante même pour la vitesse par défaut dans les cas les moins favorables. Portée à 24px, confortable pour toute vitesse raisonnablement configurée sans jamais déclencher "à la collision" alors que les objets sont encore visiblement séparés. Co-Authored-By: Claude Sonnet 5 --- static/js/play/collision-rules-controller.js | 29 ++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js index beba63a4..e9ae0569 100644 --- a/static/js/play/collision-rules-controller.js +++ b/static/js/play/collision-rules-controller.js @@ -32,17 +32,24 @@ function forgeElementBoxRect(el, collision) { return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision); } -// Une petite marge (px) appliquée SEULEMENT à la détection du -// déclencheur "collision" ci-dessous — jamais au blocage physique du -// déplacement (forgeWouldCollide, personnage-controller.js, qui reste -// strict). Bug corrigé : un objet est presque toujours aussi un -// OBSTACLE SOLIDE (collision activée par défaut) — le joueur ne peut -// donc jamais aller au-delà d'un contact tangent (bords qui se touchent -// SANS se chevaucher), et un chevauchement STRICT (forgeShapesOverlap -// nu) n'était donc jamais atteint : la règle "à la collision" ne se -// déclenchait jamais. Cette marge élargit la boîte testée pour que ce -// simple contact compte comme une collision. -var FORGE_COLLISION_RULE_TOUCH_MARGIN = 3; +// Une marge (px) appliquée SEULEMENT à la détection du déclencheur +// "collision" ci-dessous — jamais au blocage physique du déplacement +// (forgeWouldCollide, personnage-controller.js, qui reste strict). Bug +// corrigé : un objet est presque toujours aussi un OBSTACLE SOLIDE +// (collision activée par défaut) — le joueur ne peut donc jamais aller +// au-delà d'un contact tangent, et un chevauchement STRICT +// (forgeShapesOverlap nu) n'était donc jamais atteint : la règle "à la +// collision" ne se déclenchait jamais. forgeWouldCollide bloque le +// déplacement PAR PAS ENTIER (cmd.vitesse px/tick, 4 par défaut mais +// réglable plus haut dans "Commandes") : le joueur peut donc rester +// bloqué jusqu'à PRESQUE un pas entier de distance de l'obstacle, pas +// seulement 1-2px — une marge trop petite (ex. 3px) restait donc +// insuffisante pour une vitesse par défaut déjà. 24px couvre +// confortablement toute vitesse raisonnablement configurée, tout en +// restant petit face à la taille d'un sprite (souvent 64-128px) pour ne +// jamais déclencher "à la collision" alors que les objets sont encore +// visiblement séparés. +var FORGE_COLLISION_RULE_TOUCH_MARGIN = 24; function forgeExpandRect(rect, margin) { return { From 00f9cf33e96a2e425a5e1a69fe9eed03c1736cb5 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 12:58:00 +0200 Subject: [PATCH 07/23] =?UTF-8?q?Fix=20bulle=20"Appuie=20sur=20X"=20invisi?= =?UTF-8?q?ble=20+=20retire=20Attaque/=C3=89v=C3=A9nement=20du=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug corrigé : la bulle "Appuie sur X" était ajoutée comme ENFANT de l'objet de scène (un pour "personnage"/"decor"/"fond") — un élément REMPLACÉ dont les enfants DOM ajoutés en JS ne sont JAMAIS affichés par un navigateur, quel que soit son CSS. La bulle existait donc bien dans le DOM (aucune erreur) mais restait invisible à l'écran. Elle est maintenant ajoutée comme SŒUR de l'objet dans son parent (.sceneWorld/.sceneUI), positionnée en JS aux mêmes coordonnées. Assistant "+ Action" de l'éditeur de collision : "⚔️ Attaquer la cible" et "📣 Déclencher un événement" retirés des choix proposés (pas besoin pour le moment) — le serveur continue d'accepter/d'exécuter une règle déjà enregistrée avec l'un des deux, rien ne casse pour l'existant. Co-Authored-By: Claude Sonnet 5 --- .../collision-rules-controller.test.js | 46 +++++++++++++------ static/js/play/collision-rules-controller.js | 35 ++++++++++---- static/js/scenes/collision-rules-editor.js | 7 ++- static/style.css | 14 +++++- 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js index 114efed5..4bc734c9 100644 --- a/static/js/play/__tests__/collision-rules-controller.test.js +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -20,20 +20,37 @@ const { forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers, } = require('../collision-rules-controller.js'); -function fakeEl() { - const classes = new Set(); +// La bulle "Appuie sur X" est ajoutée comme SŒUR de l'objet dans son +// PARENT (jamais enfant de l'objet lui-même — un ne peut afficher +// aucun enfant, voir forgeShowCollisionInteractBubble) : chaque +// fakeEl() partage donc un parentElement commun, comme dans le vrai DOM +// (.sceneWorld/.sceneUI). +function fakeParentEl() { const children = []; + return { + children, + appendChild(child) { children.push(child); }, + querySelector(sel) { + const m = sel.match(/data-for-object-id="(\d+)"/); + return m ? children.find(c => c._forObjectId === m[1]) : undefined; + }, + }; +} + +function fakeEl(parentEl) { + const classes = new Set(); return { style: { left: '0px', top: '0px', width: '20px', height: '20px' }, classList: { add: c => classes.add(c), remove: c => classes.delete(c), toggle: c => {}, contains: c => classes.has(c) }, - children, - appendChild(child) { children.push(child); }, - querySelector(sel) { return sel === '.collisionInteractBubble' ? children.find(c => c.className === 'collisionInteractBubble') : undefined; }, + parentElement: parentEl, }; } function fakeBubbleEl() { - return { className: 'collisionInteractBubble', style: {}, textContent: '' }; + return { + className: 'collisionInteractBubble', style: {}, textContent: '', + setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); }, + }; } function fakeDocument(elByObjectId) { @@ -122,24 +139,27 @@ test('tick — la collision entre le joueur et un ennemi déclenche "attaque" un test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => { const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } }; const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] }; - const playerEl = fakeEl(); - const pnjElNear = fakeEl(); + const parentNear = fakeParentEl(); + const parentFar = fakeParentEl(); + const playerElNear = fakeEl(parentNear); + const pnjElNear = fakeEl(parentNear); pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; - const pnjElFar = fakeEl(); + const playerElFar = fakeEl(parentFar); + const pnjElFar = fakeEl(parentFar); pnjElFar.style = { left: '300px', top: '300px', width: '20px', height: '20px' }; const previousDoc = global.document; global.gameData = { personnage_animations: {} }; try { - global.document = fakeDocument({ 1: playerEl, 3: pnjElNear }); + global.document = fakeDocument({ 1: playerElNear, 3: pnjElNear }); forgeCollisionRuleTick({ elements: [player, pnj] }); - let bubble = pnjElNear.querySelector('.collisionInteractBubble'); + let bubble = parentNear.querySelector('.collisionInteractBubble[data-for-object-id="3"]'); assert.equal(bubble.style.display, 'block'); assert.equal(bubble.textContent, 'Appuie sur e'); - global.document = fakeDocument({ 1: playerEl, 3: pnjElFar }); + global.document = fakeDocument({ 1: playerElFar, 3: pnjElFar }); forgeCollisionRuleTick({ elements: [player, pnj] }); - bubble = pnjElFar.querySelector('.collisionInteractBubble'); + bubble = parentFar.querySelector('.collisionInteractBubble[data-for-object-id="3"]'); assert.equal(bubble, undefined); // jamais créée pour cet élément-là (jamais actif dessus) } finally { global.document = previousDoc; } }); diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js index e9ae0569..e5e52d16 100644 --- a/static/js/play/collision-rules-controller.js +++ b/static/js/play/collision-rules-controller.js @@ -74,24 +74,41 @@ function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { return dist <= (rule.perimetre_px || 0); } -function forgeCollisionInteractBubbleEl(objectEl) { - var bubble = objectEl.querySelector('.collisionInteractBubble'); +// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un +// objet de scène (voir render_scene_object.py) est un tout simple +// pour "personnage"/"decor"/"fond" — un élément REMPLACÉ, sans modèle de +// contenu, dont les enfants ajoutés en DOM ne sont JAMAIS affichés par +// un navigateur (bug corrigé : la bulle existait bien dans le DOM mais +// restait invisible). Ajoutée comme SŒUR de objectEl (dans son parent, +// déjà position:relative — .sceneWorld/.sceneUI, voir static/style.css), +// positionnée en JS aux mêmes coordonnées que l'objet. +function forgeCollisionInteractBubbleEl(objectId, parentEl) { + var bubble = parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'collisionInteractBubble'; - objectEl.appendChild(bubble); + bubble.setAttribute('data-for-object-id', objectId); + parentEl.appendChild(bubble); } return bubble; } -function forgeShowCollisionInteractBubble(objectEl, key) { - var bubble = forgeCollisionInteractBubbleEl(objectEl); +function forgeShowCollisionInteractBubble(objectEl, objectId, key) { + var parentEl = objectEl.parentElement; + if (!parentEl) return; + var bubble = forgeCollisionInteractBubbleEl(objectId, parentEl); bubble.textContent = 'Appuie sur ' + key; bubble.style.display = 'block'; + var left = parseFloat(objectEl.style.left) || 0; + var top = parseFloat(objectEl.style.top) || 0; + var width = parseFloat(objectEl.style.width) || 0; + bubble.style.left = (left + width / 2) + 'px'; // centrée horizontalement (voir transform:translateX(-50%), static/style.css) + bubble.style.top = top + 'px'; // AU-DESSUS de l'objet (voir transform:translateY(-100%), static/style.css) } -function forgeHideCollisionInteractBubble(objectEl) { - var bubble = objectEl.querySelector('.collisionInteractBubble'); +function forgeHideCollisionInteractBubble(objectId, objectEl) { + var parentEl = objectEl && objectEl.parentElement; + var bubble = parentEl && parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]'); if (bubble) bubble.style.display = 'none'; } @@ -141,7 +158,7 @@ function forgeCollisionRuleTick(screenData) { if (rule.action.type === 'interagir') { if (isActive) { anyInteractActive = true; - forgeShowCollisionInteractBubble(targetEl, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E'); + forgeShowCollisionInteractBubble(targetEl, obj.id, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E'); } forgeCollisionRuleActiveState[stateKey] = isActive; return; @@ -150,7 +167,7 @@ function forgeCollisionRuleTick(screenData) { if (isActive && !wasActive) forgeRunCollisionRuleAction(rule.action, targetEl, obj); forgeCollisionRuleActiveState[stateKey] = isActive; }); - if (!anyInteractActive) forgeHideCollisionInteractBubble(targetEl); + if (!anyInteractActive) forgeHideCollisionInteractBubble(obj.id, targetEl); }); } diff --git a/static/js/scenes/collision-rules-editor.js b/static/js/scenes/collision-rules-editor.js index b2ed1b68..ea2cae5a 100644 --- a/static/js/scenes/collision-rules-editor.js +++ b/static/js/scenes/collision-rules-editor.js @@ -12,6 +12,11 @@ var COLLISION_ACTION_LABELS = { quete: '🗺️ Déclencher une quête', attaque: '⚔️ Attaquer la cible', evenement: '📣 Déclencher un événement', interagir: '🔑 Interagir avec une touche', }; +// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le +// moment, demande explicite) — server-side (screens/rendering/ +// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles, +// pour ne jamais casser une règle déjà enregistrée avec l'un des deux. +var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir']; var COLLISION_TRIGGER_LABELS = { collision: '💥 À la collision', perimetre: '📍 Dans un périmètre' }; function collisionRuleObjectLabel(obj) { @@ -189,7 +194,7 @@ function collisionWizardChooseTrigger(trigger) { // voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) : // quelle action exécuter. function renderCollisionWizardActionStep(nested) { - var types = nested ? COLLISION_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_ACTION_TYPES; + var types = nested ? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_WIZARD_ACTION_TYPES; var buttons = types.map(function (type, i) { var split = collisionSplitEmojiLabel(COLLISION_ACTION_LABELS[type]); return ''; +} + // Appelée par l'action "quete" (collision-rules-controller.js) — trouve // le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois, // comme le reste du moteur 2D). @@ -38,11 +62,18 @@ function forgeShowDialogueBox(questId) { var quest = forgeQuestById(questId); if (!quest) return; var lines = (quest.dialogues && quest.dialogues[quest.status]) || []; - if (!lines.length) return; var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); - forgeDialogueBoxState[objectId] = { lines: lines, index: 0 }; + // Aucune réplique posée pour ce statut : une quête "nouvelle" propose + // quand même directement l'offre (accepter/refuser), les autres + // statuts restent silencieux (rien à montrer). + if (!lines.length) { + if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest); + return; + } + forgeResetDialogueBoxFooter(widget, objectId); + forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 }; forgeRenderDialogueBoxLine(widget, lines[0]); widget.classList.add('is-active'); } @@ -55,6 +86,11 @@ function forgeDialogueBoxAdvance(objectId) { if (!state || !widget) return; state.index += 1; if (state.index >= state.lines.length) { + var quest = forgeQuestById(state.questId); + if (quest && quest.status === 'nouvelle') { + forgeShowQuestOffer(widget, objectId, quest); + return; + } widget.classList.remove('is-active'); delete forgeDialogueBoxState[objectId]; return; @@ -62,11 +98,50 @@ function forgeDialogueBoxAdvance(objectId) { forgeRenderDialogueBoxLine(widget, state.lines[state.index]); } +// Écran d'offre : header = "Quête : ", body = l'objectif, pied = +// Accepter/Refuser au lieu du "Suivant" habituel. +function forgeShowQuestOffer(widget, objectId, quest) { + var header = widget.querySelector('[data-dialogue-role="header"]'); + var body = widget.querySelector('[data-dialogue-role="body"]'); + var footer = forgeDialogueBoxFooterEl(widget); + if (header) header.textContent = 'Quête : ' + quest.title; + if (body) body.textContent = quest.objectif || ''; + if (footer) { + footer.innerHTML = + '' + + ''; + } + forgeDialogueBoxState[objectId] = { questId: quest.id, lines: [], index: 0 }; + widget.classList.add('is-active'); +} + +// Fait passer la quête "en_cours" — UNIQUEMENT dans l'instantané en +// mémoire (gameData.quests), jamais persisté en base : voir la note en +// tête de fichier (le statut en base est celui de DÉPART pour toute +// nouvelle partie, pas un état de partie en cours). +function forgeDialogueBoxAcceptQuest(questId, objectId) { + var quest = forgeQuestById(questId); + if (quest) quest.status = 'en_cours'; + var widget = forgeDialogueBoxWidgetById(objectId); + if (widget) widget.classList.remove('is-active'); + delete forgeDialogueBoxState[objectId]; +} + +// Referme la boîte SANS toucher au statut de la quête (reste "nouvelle") +// — la prochaine interaction rejoue donc le même dialogue depuis le +// début : impossible d'avancer la quête sans un jour l'accepter. +function forgeDialogueBoxDeclineQuest(objectId) { + var widget = forgeDialogueBoxWidgetById(objectId); + if (widget) widget.classList.remove('is-active'); + delete forgeDialogueBoxState[objectId]; +} + // static/js/play/__tests__/ (node:test) — même garde que les autres // contrôleurs de ce dossier. if (typeof module !== 'undefined' && module.exports) { module.exports = { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, + forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, }; } diff --git a/static/style.css b/static/style.css index 8873f991..151257ac 100644 --- a/static/style.css +++ b/static/style.css @@ -653,12 +653,20 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .playScreen .dialogueBoxWidget.is-active{ display:flex; } .dialogueBoxHeader{ padding:8px 12px; font-weight:700; } .dialogueBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; } -.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; } +.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; gap:8px; } .dialogueBoxNextBtn{ background:rgba(255,255,255,.15); color:inherit; border:1px solid rgba(255,255,255,.3); border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer; } .dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); } +/* Écran d'offre de quête (voir forgeShowQuestOffer, dialogue-box-controller.js) : + Accepter/Refuser remplacent le "Suivant" habituel dans le pied de la + boîte — verts/rouges pour rester lisibles sur n'importe quelle couleur + de pied choisie par l'auteur (voir "🎨 Style"). */ +.dialogueBoxAcceptBtn{ background:#2ecc71; border-color:#2ecc71; color:#0b1a10; font-weight:700; } +.dialogueBoxAcceptBtn:hover{ background:#3ddb80; } +.dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; } +.dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); } /* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur sa propre balise (voir applySelectionHighlight) — seul moyen de la voir From f2ed602459cd32a27920e02f35ac1b55b0446a29 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 14:04:26 +0200 Subject: [PATCH 11/23] =?UTF-8?q?Fix=20boutons=20Accepter/Refuser=20absent?= =?UTF-8?q?s=20de=20l'=C3=A9cran=20d'offre=20de=20qu=C3=AAte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le footer de la boîte de dialogue n'avait pas data-dialogue-role="footer" (seul son bouton interne "Suivant" l'avait, sous "next-btn") — le JS (forgeShowQuestOffer, dialogue-box-controller.js) le cherche par ce sélecteur pour y injecter les boutons Accepter/Refuser en fin de dialogue d'une quête "nouvelle". querySelector ne trouvait donc jamais le footer, et l'écran d'offre gardait silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons — exactement le bug signalé. Co-Authored-By: Claude Sonnet 5 --- screens/scenes/render_scene_object.py | 2 +- tests/test_dialogue_box.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index a895d685..aa649020 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -61,7 +61,7 @@ def render_scene_object(obj): f'
' f'
Nom du personnage
' f'
Le texte du dialogue s\'affiche ici.
' - f'
' + f'
' f'' f'
' ) diff --git a/tests/test_dialogue_box.py b/tests/test_dialogue_box.py index 056e5049..6d3fb33e 100644 --- a/tests/test_dialogue_box.py +++ b/tests/test_dialogue_box.py @@ -42,6 +42,15 @@ def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup): assert "dialogueBoxBody" in html assert "dialogueBoxFooter" in html assert f'data-object-id="{obj_id}"' in html + # Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul + # son bouton "Suivant" interne l'avait, sous "next-btn") — le JS + # (forgeShowQuestOffer, static/js/play/dialogue-box-controller.js) le + # cherche par ce sélecteur pour y injecter Accepter/Refuser en fin de + # dialogue d'une quête "nouvelle" ; sans lui, l'écran d'offre gardait + # silencieusement le bouton "Suivant" au lieu des deux nouveaux boutons. + assert 'data-dialogue-role="header"' in html + assert 'data-dialogue-role="body"' in html + assert 'data-dialogue-role="footer"' in html def test_set_scene_object_dialogue_box_style_persists_and_sanitizes(tmp_game_slug_cleanup): From e972acf9e65fbf06765682cbb48f1776ef828a7f Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 14:48:14 +0200 Subject: [PATCH 12/23] =?UTF-8?q?Qu=C3=AAtes=20:=20bulle=20"=E2=9D=93=20Qu?= =?UTF-8?q?estion"=20=C3=A0=20choix=20multiples,=20=C3=A0=20c=C3=B4t=C3=A9?= =?UTF-8?q?=20de=20"+=20R=C3=A9plique"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau type de ligne dans un dialogue de quête (voir db/quests/sanitize_quest_dialogues.py) : une bulle JAUNE, dans la même chaîne verticale que les répliques (même alignement, reliée par un trait) — header : nombre de choix (2 à 8) + récompense (score, montant en points) ; body : la question, ses choix et un bouton radio pour désigner la bonne réponse. Aucune limite au nombre de questions par colonne, comme pour une réplique. Les lignes de dialogue portent maintenant explicitement {"type": "dialogue", ...} (au lieu d'un objet sans type) pour distinguer les deux formes — migration de forme, tests mis à jour. Portée de ce commit : l'ÉDITEUR (créer/modifier une question). Le jeu lui-même (afficher la question au joueur, vérifier la réponse, attribuer la récompense) reste à construire — le widget "💬 Boîte de dialogue" ne sait aujourd'hui afficher qu'une réplique. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 4 +- db/quests/sanitize_quest_dialogues.py | 89 +++++++++++++++----- static/js/quests/quest-editor.js | 116 +++++++++++++++++++++++++- static/style.css | 29 ++++++- tests/test_quests.py | 38 +++++++-- 5 files changed, 242 insertions(+), 34 deletions(-) diff --git a/db/__init__.py b/db/__init__.py index 35b2eb84..e3874e41 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -87,7 +87,7 @@ from .quests.create_quest import create_quest from .quests.update_quest import update_quest from .quests.delete_quest import delete_quest from .quests.set_quest_dialogues import set_quest_dialogues -from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues +from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", @@ -108,5 +108,5 @@ __all__ = [ "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "list_quests", "get_quest", "create_quest", "update_quest", "delete_quest", - "set_quest_dialogues", "sanitize_quest_dialogues", + "set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", ] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index aa3a9708..d17828af 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -3,18 +3,73 @@ from .constants import QUEST_STATUS_CHOICES _MAX_LINES_PER_COLUMN = 200 _MAX_SPEAKER_LENGTH = 60 +# Question à choix (voir "❓ Question", static/js/quests/quest-editor.js) : +# une bulle jaune posée dans le même enchaînement qu'une réplique de +# dialogue — plusieurs choix, une seule bonne réponse, une récompense. +# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/ +# db.set_score_value) — liste plutôt qu'un booléen pour pouvoir en ajouter +# d'autres plus tard sans changer la forme des données. +QUESTION_REWARD_TYPES = ["score"] +_MIN_CHOICES = 2 +_MAX_CHOICES = 8 + + +def _sanitize_dialogue_line(raw_line): + speaker = raw_line.get("speaker") + text = raw_line.get("text") + if not isinstance(speaker, str) or not speaker.strip(): + 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()} + + +def _sanitize_question_line(raw_line): + text = raw_line.get("text") + if not isinstance(text, str) or not text.strip(): + return None + raw_choices = raw_line.get("choices") + if not isinstance(raw_choices, list): + return None + choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES] + if len(choices) < _MIN_CHOICES: + return None + try: + correct_index = int(raw_line.get("correct_index")) + except (TypeError, ValueError): + return None + if not (0 <= correct_index < len(choices)): + return None + reward_type = raw_line.get("reward_type") + if reward_type not in QUESTION_REWARD_TYPES: + reward_type = QUESTION_REWARD_TYPES[0] + try: + reward_amount = max(0, int(raw_line.get("reward_amount", 0))) + except (TypeError, ValueError): + reward_amount = 0 + return { + "type": "question", "text": text.strip(), "choices": choices, + "correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount, + } + + +def _sanitize_line(raw_line): + if not isinstance(raw_line, dict): + return None + if raw_line.get("type") == "question": + return _sanitize_question_line(raw_line) + return _sanitize_dialogue_line(raw_line) + def sanitize_quest_dialogues(raw_dialogues): - """Une réplique = {speaker: '', text: '...'} — le - nom vient du champ "ℹ️ Informations" d'un objet de scène kind= - "personnage" (voir screens/rendering/scene_object_names.py), ou - "Joueur" (toujours proposé, voir static/js/quests/quest-editor.js) : - texte libre côté serveur (jamais un enum fixe — n'importe quel nom - donné à un personnage doit être accepté), juste borné en longueur. - Une colonne par statut de quête (voir QUEST_STATUS_CHOICES), même - esprit que resolve_collision_rules.sanitize_collision_rules : rejette - tout élément invalide plutôt que de lever, ne garde jamais un texte - ou un nom vide.""" + """Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de + répliques de dialogue ({type: "dialogue", speaker, text} — le nom + vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur", + voir screens/rendering/scene_object_names.py) ET/OU de questions à + choix ({type: "question", text, choices, correct_index, reward_type, + reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit + que resolve_collision_rules.sanitize_collision_rules : rejette tout + élément invalide plutôt que de lever.""" result = {status: [] for status in QUEST_STATUS_CHOICES} if not isinstance(raw_dialogues, dict): return result @@ -22,16 +77,6 @@ def sanitize_quest_dialogues(raw_dialogues): raw_lines = raw_dialogues.get(status) if not isinstance(raw_lines, list): continue - cleaned = [] - for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]: - if not isinstance(raw_line, dict): - continue - speaker = raw_line.get("speaker") - text = raw_line.get("text") - if not isinstance(speaker, str) or not speaker.strip(): - continue - if not isinstance(text, str) or not text.strip(): - continue - cleaned.append({"speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}) - result[status] = cleaned + cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]] + result[status] = [line for line in cleaned if line] return result diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index a32b685e..0f16fb83 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -231,14 +231,66 @@ function questDialogueBubbleHtml(line, status, index) { ); } +function questEscAttr(str) { + return (str || '').replace(/"/g, '"'); +} + +// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de +// dialogue (même colonne, même alignement vertical relié par un trait, +// voir .questBubbleRow/.questBubble ci-dessus et static/style.css) mais +// un type de ligne différent (voir db/quests/sanitize_quest_dialogues.py) : +// header = nombre de choix + récompense (type + montant), body = la +// question et ses choix, avec la bonne réponse cochée. Aucune limite au +// nombre de questions par colonne (voir questAddQuestion), comme pour +// une réplique. +function questQuestionChoiceRowHtml(choice, choiceIndex, correctIndex, status, index) { + var radioName = 'questCorrect-' + status + '-' + index; + return ( + '
' + + '' + + '' + + '
' + ); +} + +function questQuestionBubbleHtml(line, status, index) { + var choicesHtml = line.choices.map(function (c, i) { return questQuestionChoiceRowHtml(c, i, line.correct_index, status, index); }).join(''); + return ( + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
' + choicesHtml + '
' + + '
' + + '
' + + '
' + ); +} + function questDialogueColumnHtml(quest, status) { var lines = (quest.dialogues && quest.dialogues[status]) || []; - var bubblesHtml = lines.map(function (line, i) { return questDialogueBubbleHtml(line, status, i); }).join(''); + var bubblesHtml = lines.map(function (line, i) { + return line.type === 'question' ? questQuestionBubbleHtml(line, status, i) : questDialogueBubbleHtml(line, status, i); + }).join(''); return ( '
' + '

' + QUEST_STATUS_LABELS[status] + '

' + '
' + bubblesHtml + '
' + - '' + + '
' + + '' + + '' + + '
' + '
' ); } @@ -351,11 +403,69 @@ function questAddBubble(status) { quest.dialogues = quest.dialogues || {}; var lines = quest.dialogues[status] || (quest.dialogues[status] = []); var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ'; - lines.push({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' }); + lines.push({ type: 'dialogue', speaker: questNextDefaultSpeaker(lastSpeaker), text: '' }); renderQuestDialogueModalBody(quest.id); questSaveDialogues(quest); } +// "❓ Question" — voir questQuestionBubbleHtml ci-dessus. Défauts : 2 +// choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt +// (l'auteur règle le montant dans le header de la bulle). +function questAddQuestion(status) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues = quest.dialogues || {}; + var lines = quest.dialogues[status] || (quest.dialogues[status] = []); + lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 }); + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questUpdateQuestionText(status, index, text) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].text = text; + questSaveDialogues(quest); +} + +// Ajuste le NOMBRE de choix (2 à 8, voir db/quests/sanitize_quest_dialogues.py +// ::_MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les +// derniers, sans jamais toucher aux choix déjà remplis qui restent dans +// la nouvelle taille. Recadre la bonne réponse sur le premier choix si +// elle pointait vers un choix retiré. +function questSetQuestionChoiceCount(status, index, value) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + var line = quest.dialogues[status][index]; + var count = Math.max(2, Math.min(8, parseInt(value, 10) || 2)); + 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; + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questSetQuestionChoiceText(status, index, choiceIndex, text) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].choices[choiceIndex] = text; + questSaveDialogues(quest); +} + +function questSetQuestionCorrectIndex(status, index, choiceIndex) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].correct_index = choiceIndex; + questSaveDialogues(quest); +} + +function questSetQuestionRewardAmount(status, index, value) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].reward_amount = Math.max(0, parseInt(value, 10) || 0); + questSaveDialogues(quest); +} + function questSetBubbleSpeaker(status, index, speaker) { var quest = questById(_currentQuestModalId()); if (!quest) return; diff --git a/static/style.css b/static/style.css index 151257ac..0ec232e7 100644 --- a/static/style.css +++ b/static/style.css @@ -854,7 +854,9 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d CETTE zone défile (flex:1 + overflow-y:auto) — le titre de colonne et le bouton "+ Réplique" restent fixes, en haut/en bas. */ .questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; overflow-y:auto; padding-top:4px; } -.questDialogueAddBtn{ flex:0 0 auto; align-self:center; padding:6px 14px; font-size:12px; margin-top:4px; } +.questDialogueColumnButtons{ flex:0 0 auto; display:flex; gap:8px; justify-content:center; margin-top:4px; } +.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } +.questQuestionAddBtn{ background:linear-gradient(135deg, #e0b93d, #b8890f); } .questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; } .questBubbleRow:not(:first-child)::before{ @@ -874,6 +876,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); } .questBubble-joueur .questBubbleHeader{ background:#1e9e57; } .questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); } +/* "❓ Question" (voir questQuestionBubbleHtml, static/js/quests/ + quest-editor.js) — bulle JAUNE distincte des répliques de dialogue, + même chaîne/alignement vertical (.questBubbleRow). */ +.questBubble-question .questBubbleHeader{ background:#b8890f; flex-wrap:wrap; row-gap:4px; } +.questBubble-question .questBubbleBody{ background:rgba(224,185,61,.14); } +.questQuestionHeaderField{ + display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:700; color:#fff; white-space:nowrap; +} +.questQuestionHeaderField input{ + width:44px; background:rgba(0,0,0,.25); border:1px solid rgba(255,255,255,.3); border-radius:4px; + color:#fff; font:inherit; font-size:11px; padding:2px 4px; +} +.questQuestionText{ + width:100%; background:rgba(0,0,0,.2); border:1px solid var(--border); border-radius:6px; + color:#fff; font:inherit; font-size:13px; padding:6px 8px; margin-bottom:8px; +} +.questQuestionText::placeholder{ color:rgba(255,255,255,.5); } +.questChoicesList{ display:flex; flex-direction:column; gap:6px; } +.questChoiceRow{ display:flex; align-items:center; gap:6px; } +.questChoiceRadio{ accent-color:#e0b93d; flex-shrink:0; } +.questChoiceInput{ + flex:1; background:rgba(0,0,0,.2); border:1px solid var(--border); border-radius:6px; + color:#fff; font:inherit; font-size:12.5px; padding:4px 8px; +} +.questChoiceInput::placeholder{ color:rgba(255,255,255,.5); } .questBubbleSpeaker{ font-size:11px; font-weight:700; color:#fff; cursor:pointer; background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; flex:1; diff --git a/tests/test_quests.py b/tests/test_quests.py index a5045ad3..8d485e19 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -86,19 +86,45 @@ def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status(): } cleaned = db.sanitize_quest_dialogues(raw) assert cleaned == { - "nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}], + "nouvelle": [{"type": "dialogue", "speaker": "Le Sorcier", "text": "Bonjour !"}], "en_cours": [], "terminee": [], } +def test_sanitize_quest_dialogues_accepts_a_valid_question_and_rejects_malformed_ones(): + """"❓ Question" (voir static/js/quests/quest-editor.js) : une bulle + jaune avec plusieurs choix, une seule bonne réponse, une récompense — + même chaîne qu'une réplique de dialogue, un autre "type" de ligne.""" + raw = { + "nouvelle": [ + {"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2}, + {"type": "question", "text": "Une seule option", "choices": ["seule"], "correct_index": 0}, # < 2 choix -> rejetée + {"type": "question", "text": "index hors bornes", "choices": ["a", "b"], "correct_index": 5}, # rejetée + {"type": "question", "text": " ", "choices": ["a", "b"], "correct_index": 0}, # texte vide -> rejetée + {"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "vie"}, + ], + "en_cours": [], "terminee": [], + } + cleaned = db.sanitize_quest_dialogues(raw) + assert cleaned["nouvelle"] == [ + {"type": "question", "text": "2+2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "reward_type": "score", "reward_amount": 2}, + # reward_type invalide -> repli sur le premier type connu ("score"), jamais rejetée pour ça seul + {"type": "question", "text": "type de récompense inconnu", "choices": ["a", "b"], "correct_index": 0, "reward_type": "score", "reward_amount": 0}, + ] + + def test_set_quest_dialogues_persists_all_three_columns(tmp_game_slug_cleanup): slug = _make_game(tmp_game_slug_cleanup) quest = db.create_quest(slug, "Trouver la clé") dialogues = { - "nouvelle": [{"speaker": "pnj", "text": "Peux-tu m'aider ?"}, {"speaker": "joueur", "text": "Bien sûr !"}], - "en_cours": [{"speaker": "pnj", "text": "Alors, cette clé ?"}], - "terminee": [{"speaker": "pnj", "text": "Merci beaucoup !"}], + "nouvelle": [ + {"type": "dialogue", "speaker": "pnj", "text": "Peux-tu m'aider ?"}, + {"type": "dialogue", "speaker": "joueur", "text": "Bien sûr !"}, + {"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_type": "score", "reward_amount": 2}, + ], + "en_cours": [{"type": "dialogue", "speaker": "pnj", "text": "Alors, cette clé ?"}], + "terminee": [{"type": "dialogue", "speaker": "pnj", "text": "Merci beaucoup !"}], } db.set_quest_dialogues(slug, quest["id"], dialogues) reloaded = db.get_quest(slug, quest["id"]) @@ -159,8 +185,8 @@ def test_quest_dialogues_route_sanitizes_and_persists(client, tmp_game_slug_clea data='{"dialogues": {"nouvelle": [{"speaker": "pnj", "text": "Salut !"}], "en_cours": [], "terminee": []}}', content_type="application/json", ) - assert resp.get_json()["dialogues"]["nouvelle"] == [{"speaker": "pnj", "text": "Salut !"}] - assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"speaker": "pnj", "text": "Salut !"}] + assert resp.get_json()["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}] + assert db.get_quest(slug, quest["id"])["dialogues"]["nouvelle"] == [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}] def test_quest_dialogues_route_404s_for_missing_quest(client, game): From b5546df044e587c3c3ae4b376b7a9d34633edb67 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 15:42:42 +0200 Subject: [PATCH 13/23] =?UTF-8?q?Quiz=20jouable=20:=20budget=20de=20r?= =?UTF-8?q?=C3=A9compense,=20bo=C3=AEte=20=C3=A0=20quiz,=20widget=20score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Récompense d'une quête = budget MAXIMUM pour ses questions : la somme des points des "❓ Question" ne peut plus dépasser recompense_score (routes/quests/quest_dialogues.py à l'enregistrement des dialogues, quest_update.py si on abaisse la récompense sous ce qui est déjà réparti) — message d'erreur explicite (400) dans les deux sens, affiché via alert() côté éditeur (quest-editor.js), jamais enregistré silencieusement dans un état incohérent. - Nombre de choix par question plafonné à 4 (au lieu de 8). - Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte de dialogue" — screens/rendering/dialogue_box_style.py, même panneau "🎨 Style") : - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une question se résout au clic sur un choix, pas de "Suivant"). - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible une fois posé (contrairement aux boîtes de dialogue/quiz, masquées par défaut). - Moteur d'exécution (static/js/play/dialogue-box-controller.js, refonte) : une conversation de quête alterne maintenant répliques (boîte de dialogue) et questions (boîte à quiz) selon le type de chaque ligne. Bonne réponse -> crédite le score (compteur runtime dédié, jamais lié au score du moteur document) et avance ; mauvaise réponse -> rien, la question reste affichée pour réessayer ; aucune boîte à quiz posée -> la question est ignorée plutôt que de bloquer la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait des questions, toutes répondues) fait passer la quête "terminee" (en mémoire seulement, comme le reste de ce système — voir accepter/ refuser une offre de quête) : le joueur peut alors quitter la scène normalement, ces widgets n'étant jamais des obstacles. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 3 +- db/quests/reward_budget.py | 15 ++ db/quests/sanitize_quest_dialogues.py | 2 +- routes/quests/quest_dialogues.py | 25 ++- routes/quests/quest_update.py | 22 ++ routes/scenes/scene_edit_view.py | 7 +- screens/payload/full_game_payload.py | 6 +- screens/scenes/add_scene_object.py | 15 +- screens/scenes/render_scene_object.py | 44 ++++ .../__tests__/dialogue-box-controller.test.js | 124 +++++++++++ static/js/play/dialogue-box-controller.js | 208 ++++++++++++++---- static/js/play/personnage-controller.js | 2 +- static/js/play/screens.js | 6 + static/js/quests/quest-editor.js | 27 ++- static/style.css | 28 +++ templates/play.html | 8 +- templates/scene_edit.html | 29 ++- tests/test_dialogue_box.py | 63 ++++++ tests/test_quests.py | 70 ++++++ 19 files changed, 628 insertions(+), 76 deletions(-) create mode 100644 db/quests/reward_budget.py diff --git a/db/__init__.py b/db/__init__.py index e3874e41..25f3a850 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -88,6 +88,7 @@ from .quests.update_quest import update_quest from .quests.delete_quest import delete_quest from .quests.set_quest_dialogues import set_quest_dialogues from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES +from .quests.reward_budget import quest_dialogues_total_reward __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", @@ -108,5 +109,5 @@ __all__ = [ "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "list_quests", "get_quest", "create_quest", "update_quest", "delete_quest", - "set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", + "set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward", ] diff --git a/db/quests/reward_budget.py b/db/quests/reward_budget.py new file mode 100644 index 00000000..68d66601 --- /dev/null +++ b/db/quests/reward_budget.py @@ -0,0 +1,15 @@ +def quest_dialogues_total_reward(dialogues): + """Somme des récompenses de toutes les questions ("❓ Question", voir + sanitize_quest_dialogues.py), TOUTES colonnes/statuts confondus — une + quête n'a qu'UNE SEULE récompense totale (quest["recompense_score"]), + jamais une par statut. Utilisée pour empêcher d'enregistrer plus de + points répartis entre les questions que la quête n'en offre (voir + routes/quests/quest_dialogues.py et quest_update.py).""" + total = 0 + for lines in (dialogues or {}).values(): + if not isinstance(lines, list): + continue + for line in lines: + if isinstance(line, dict) and line.get("type") == "question": + total += line.get("reward_amount", 0) or 0 + return total diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index d17828af..2f026047 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -11,7 +11,7 @@ _MAX_SPEAKER_LENGTH = 60 # d'autres plus tard sans changer la forme des données. QUESTION_REWARD_TYPES = ["score"] _MIN_CHOICES = 2 -_MAX_CHOICES = 8 +_MAX_CHOICES = 4 def _sanitize_dialogue_line(raw_line): diff --git a/routes/quests/quest_dialogues.py b/routes/quests/quest_dialogues.py index 2f318de1..379ba347 100644 --- a/routes/quests/quest_dialogues.py +++ b/routes/quests/quest_dialogues.py @@ -9,9 +9,26 @@ from core.flask_app import app def quest_dialogues(slug, quest_id): """Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) — le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet - complet à chaque modification plutôt qu'une réplique isolée.""" - body = request.get_json(silent=True) or {} - dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues")) - if dialogues is None: + complet à chaque modification plutôt qu'une réplique isolée. + + Refusé (400) si la somme des récompenses des questions ("❓ Question") + dépasse la récompense totale de la quête (recompense_score) — voir + db.quest_dialogues_total_reward. Validé ICI (avant persistance) + plutôt que dans db.set_quest_dialogues, pour renvoyer un message + d'erreur explicite au lieu d'enregistrer silencieusement un état + incohérent.""" + quest = db.get_quest(slug, quest_id) + if quest is None: return jsonify({"error": "quête introuvable"}), 404 + body = request.get_json(silent=True) or {} + sanitized = db.sanitize_quest_dialogues(body.get("dialogues")) + allocated = db.quest_dialogues_total_reward(sanitized) + if allocated > quest["recompense_score"]: + return jsonify({ + "error": ( + f"La somme des récompenses des questions ({allocated} pts) dépasse " + f"la récompense totale de la quête ({quest['recompense_score']} pts)." + ), + }), 400 + dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues")) return jsonify({"dialogues": dialogues}) diff --git a/routes/quests/quest_update.py b/routes/quests/quest_update.py index 2c922c82..46f5384f 100644 --- a/routes/quests/quest_update.py +++ b/routes/quests/quest_update.py @@ -7,7 +7,29 @@ from core.flask_app import app @app.route("/game//quests/", methods=["POST"]) def quest_update(slug, quest_id): + """Refusé (400) si "recompense_score" est abaissé SOUS le total déjà + réparti entre les questions ("❓ Question") du dialogue de cette + quête — voir db.quest_dialogues_total_reward, même contrainte que + routes/quests/quest_dialogues.py mais dans l'autre sens (ici c'est le + budget total qui bouge, pas la répartition).""" body = request.get_json(silent=True) or {} + if "recompense_score" in body: + quest = db.get_quest(slug, quest_id) + if quest is None: + return jsonify({"error": "quête introuvable"}), 404 + try: + new_score = int(body["recompense_score"]) + except (TypeError, ValueError): + new_score = None + if new_score is not None: + allocated = db.quest_dialogues_total_reward(quest["dialogues"]) + if new_score < allocated: + return jsonify({ + "error": ( + f"La récompense totale ({new_score} pts) est inférieure aux " + f"{allocated} pts déjà répartis entre les questions du dialogue." + ), + }), 400 quest = db.update_quest(slug, quest_id, body) if quest is None: return jsonify({"error": "quête introuvable"}), 404 diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index f0a53f9d..d4276a37 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -28,14 +28,15 @@ def render_scene_edit(slug, screen_id, game): collision_settings = None dialogue_box_style = None selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None + _UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget") if selected_obj: - if selected_obj["kind"] != "dialogue_box": + if selected_obj["kind"] not in _UI_WIDGET_KINDS: collision_settings = screens.resolve_collision_settings(selected_obj) if selected_obj["kind"] == "personnage": personnage_data = screens.resolve_personnage_data(selected_obj) personnage_commands = screens.resolve_personnage_commands(selected_obj) personnage_role = screens.resolve_personnage_role(selected_obj) - elif selected_obj["kind"] == "dialogue_box": + elif selected_obj["kind"] in _UI_WIDGET_KINDS: dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj) _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} @@ -56,7 +57,7 @@ def render_scene_edit(slug, screen_id, game): # SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages). collision_rule_objects = [] for o in objects: - if o["kind"] in ("fond", "dialogue_box"): + if o["kind"] in ("fond", "dialogue_box", "quiz_box", "score_widget"): continue role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None if role == "joueur": diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 534fa6a0..327d16e9 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -58,10 +58,10 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): if o["kind"] == "personnage": o["personnage_commandes"] = resolve_personnage_commands(o) o["personnage_role"] = resolve_personnage_role(o) - if o["kind"] == "dialogue_box": - # Widget d'interface (voir "🖥️ Interface", + if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"): + # Widgets d'interface (voir "🖥️ Interface", # screens/rendering/dialogue_box_style.py) : ni - # collision, ni cible/source de règle — juste son + # collision, ni cible/source de règle — juste leur # style, lu par static/js/play/dialogue-box-controller.js. o["dialogue_box_style"] = resolve_dialogue_box_style(o) continue diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index abf56f16..f53d12df 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -5,11 +5,14 @@ import db from .ensure_scene_schema import ensure_scene_schema _DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"} -_KINDS = {"personnage", "decor", "fond", "dialogue_box"} -# Widget d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) : -# assez grand pour lire une réplique sans redimensionner, jamais confondu -# visuellement avec un personnage/décor (128×128 par défaut). +_KINDS = {"personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"} +# Widgets d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) : +# assez grands pour lire une réplique/question sans redimensionner, +# jamais confondus visuellement avec un personnage/décor (128×128 par +# défaut). "score_widget" reste petit — juste un nombre. _DIALOGUE_BOX_DEFAULT_SIZE = (320, 150) +_QUIZ_BOX_DEFAULT_SIZE = (340, 240) +_SCORE_WIDGET_DEFAULT_SIZE = (140, 50) def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None): @@ -46,6 +49,10 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba width, height = background["width"], background["height"] elif kind == "dialogue_box": width, height = _DIALOGUE_BOX_DEFAULT_SIZE + elif kind == "quiz_box": + width, height = _QUIZ_BOX_DEFAULT_SIZE + elif kind == "score_widget": + width, height = _SCORE_WIDGET_DEFAULT_SIZE conn = db.connect(slug) if kind == "fond": z_index = -1000 diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index aa649020..74f85ee9 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -65,6 +65,50 @@ def render_scene_object(obj): f'' f'
' ) + if obj["kind"] == "quiz_box": + # Widget "❓ Boîte à quiz" (voir "🖥️ Interface") — réutilise le + # MÊME style que "💬 Boîte de dialogue" (resolve_dialogue_box_style/ + # set_scene_object_dialogue_box_style, une seule "🎨 Style" pour + # les deux) : header = la question, corps = les choix de réponse + # (boutons remplis EN JEU par static/js/play/dialogue-box-controller.js, + # data-quiz-role sert de sélecteur) — pas de pied (une question se + # résout au clic sur un choix, pas de "Suivant"). Masqué par + # défaut au jeu, comme la boîte de dialogue. + style = resolve_dialogue_box_style(obj) + box_style = ( + f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " + f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; " + f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; " + f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};" + ) + return ( + f'
' + f'
La question s\'affiche ici.
' + f'
' + f'' + f'' + f'
' + ) + if obj["kind"] == "score_widget": + # Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une + # fois posé (pas de collision/déclencheur, contrairement aux deux + # widgets ci-dessus) : affiche en continu le score de la partie en + # cours (voir static/js/play/dialogue-box-controller.js:: + # forgeUpdateAllScoreWidgets). Même style que les deux autres + # widgets, header_bg sert de fond unique (pas de zone body/footer). + style = resolve_dialogue_box_style(obj) + box_style = ( + f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " + f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; " + f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; " + f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])}; " + f"background:{html_lib.escape(style['header_bg'])};" + ) + return ( + f'
' + f'🏆 0' + f'
' + ) if obj["kind"] == "personnage": src = resolve_personnage_idle_frame(obj) src_attr = f' src="{html_lib.escape(src)}"' if src else "" diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index c9b899d0..b76269d4 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -11,6 +11,7 @@ global.gameData = {}; const { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, + forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets, } = require('../dialogue-box-controller.js'); function fakeWidget(objectId) { @@ -154,3 +155,126 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet' assert.doesNotThrow(() => forgeDialogueBoxAdvance(999)); } finally { global.document = previousDoc; } }); + +// ---------- Enchaînement dialogue + question ("❓ Question") ---------- + +function fakeQuizWidget(objectId) { + const roles = { header: { textContent: '' }, choices: { innerHTML: '' } }; + const classes = new Set(); + return { + getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null), + classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) }, + querySelector: sel => { + if (sel === '[data-quiz-role="header"]') return roles.header; + if (sel === '[data-quiz-role="choices"]') return roles.choices; + return null; + }, + _roles: roles, _classes: classes, + }; +} + +function docWithDialogueAndQuiz(dialogueWidget, quizWidget) { + return { + querySelector(sel) { + if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null; + if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null; // forgeQuizBoxWidgetById + if (sel.indexOf('.dialogueBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return dialogueWidget; + if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById + return null; + }, + querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests + }; +} + +test.beforeEach(() => { + global.gameData.quests.push({ + id: 10, status: 'en_cours', + dialogues: { + nouvelle: [], + en_cours: [ + { type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' }, + { type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 }, + { type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' }, + ], + terminee: [], + }, + }); + window.forgeQuizScore = 0; +}); + +test('une ligne "question" est affichée dans la boîte à QUIZ, pas la boîte de dialogue', () => { + const dialogueWidget = fakeWidget(42); + dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null); + const quizWidget = fakeQuizWidget(99); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox(10); + assert.equal(dialogueWidget._classes.has('is-active'), true); // 1ère ligne : dialogue normal + + forgeDialogueBoxAdvance(42); // -> ligne "question" + assert.equal(dialogueWidget._classes.has('is-active'), false); // masquée pendant la question + assert.equal(quizWidget._classes.has('is-active'), true); + assert.equal(quizWidget._roles.header.textContent, '2+2 ?'); + assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 0\)/); + assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxAnswer\(99, 1\)/); + } finally { global.document = previousDoc; } +}); + +test('mauvaise réponse au quiz : aucun effet, la question reste affichée', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox(10); + forgeDialogueBoxAdvance(42); // -> question + forgeQuizBoxAnswer(99, 0); // mauvaise réponse (correct_index=1) + assert.equal(quizWidget._classes.has('is-active'), true); // reste affichée + assert.equal(window.forgeQuizScore, 0); // aucun point + } finally { global.document = previousDoc; } +}); + +test('bonne réponse au quiz : crédite le score, masque la boîte, avance à la ligne suivante', () => { + const dialogueWidget = fakeWidget(42); + const quizWidget = fakeQuizWidget(99); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); + try { + forgeShowDialogueBox(10); + forgeDialogueBoxAdvance(42); // -> question + forgeQuizBoxAnswer(99, 1); // bonne réponse + assert.equal(window.forgeQuizScore, 5); + assert.equal(quizWidget._classes.has('is-active'), false); + assert.equal(dialogueWidget._classes.has('is-active'), true); + assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); + + forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee" + assert.equal(forgeQuestById(10).status, 'terminee'); + assert.equal(dialogueWidget._classes.has('is-active'), false); + } finally { global.document = previousDoc; } +}); + +test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => { + const dialogueWidget = fakeWidget(42); + const previousDoc = global.document; + global.document = docWithDialogueAndQuiz(dialogueWidget, null); // pas de widget à quiz + try { + forgeShowDialogueBox(10); + forgeDialogueBoxAdvance(42); // -> question, mais pas de boîte à quiz -> avance directement + assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !'); // déjà à la ligne suivante + assert.equal(window.forgeQuizScore, 0); // pas de récompense sans réponse + } finally { global.document = previousDoc; } +}); + +test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => { + const values = [{ textContent: '' }, { textContent: '' }]; + const previousDoc = global.document; + window.forgeQuizScore = 42; + global.document = { querySelectorAll: () => values }; + try { + forgeUpdateAllScoreWidgets(); + assert.equal(values[0].textContent, 42); + assert.equal(values[1].textContent, 42); + } finally { global.document = previousDoc; } +}); diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index 79732c14..2fd6d1b6 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -1,32 +1,57 @@ -// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ---------- +// ---------- Widgets "💬 Boîte de dialogue" / "❓ Boîte à quiz" / "🏆 Score" ---------- // Fait le lien entre l'éditeur de collision (action "quete", voir -// static/js/play/collision-rules-controller.js) et le widget d'interface -// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, +// static/js/play/collision-rules-controller.js) et les widgets d'interface +// posés par l'auteur (voir "🖥️ Interface", templates/scene_edit.html, // screens/rendering/dialogue_box_style.py) : à l'exécution d'une action -// "quete", affiche la réplique en cours du dialogue correspondant au -// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py), -// avance d'une réplique à chaque clic sur "Suivant". +// "quete", parcourt le dialogue correspondant au STATUT ACTUEL de la +// quête (gameData.quests, voir full_game_payload.py) ligne par ligne — +// une ligne "dialogue" s'affiche dans la boîte de dialogue (bouton +// "Suivant" pour avancer) ; une ligne "question" ("❓ Question", voir +// static/js/quests/quest-editor.js) s'affiche dans la boîte à QUIZ (si +// posée sur la scène — sinon ignorée silencieusement, la conversation +// avance sans elle) : cliquer la BONNE réponse crédite le score du +// montant prévu et avance ; une mauvaise réponse ne fait RIEN, la +// question reste affichée (le joueur peut réessayer). +// +// Une fois le dialogue "nouvelle" épuisé : la boîte bascule en écran +// d'OFFRE ("Quête : " / objectif / boutons Accepter-Refuser) au +// lieu de se masquer — accepter fait passer la quête "en_cours" ; refuser +// ferme simplement la boîte SANS rien changer (la quête reste "nouvelle", +// la prochaine interaction rejoue exactement le même dialogue). Une fois +// le dialogue "en_cours" épuisé (donc, s'il contenait des questions, +// toutes répondues correctement), la quête passe "terminee" — le joueur +// peut alors quitter la scène normalement (aucun blocage, ces widgets ne +// sont jamais des obstacles). Tout ceci reste LOCAL à la partie en cours +// (gameData.quests n'est qu'un instantané par joueur, voir +// full_game_payload.py) : jamais persisté en base, qui garde le statut +// de DÉPART pour toute nouvelle partie. +// +// Le score du quiz (window.forgeQuizScore) est un compteur RUNTIME dédié +// à ce système de quête/quiz — distinct du score "classique" du moteur +// document (db/scoring/, action de flow "Modifier un score"), qui +// suppose un nœud de flow préconfiguré et un aller-retour serveur, hors +// de propos pour ce sous-système entièrement client (voir +// collision-rules-controller.js : "Pur JS client, sans le moindre appel +// serveur"). // -// Une fois le dialogue épuisé : si la quête est encore "nouvelle", la -// boîte bascule en écran d'OFFRE ("Quête : " / objectif / boutons -// Accepter-Refuser) au lieu de se masquer — accepter fait passer la -// quête "en_cours" (LOCAL à cette partie, jamais persisté en base : -// gameData.quests n'est qu'un instantané par joueur, voir -// full_game_payload.py ; muter la ligne en base changerait le statut de -// DÉPART de la quête pour toutes les parties futures) ; refuser ferme -// simplement la boîte SANS changer le statut — la quête reste "nouvelle", -// donc la prochaine interaction rejoue EXACTEMENT le même dialogue depuis -// le début (impossible d'avancer sans accepter). Pour tout autre statut -// (déjà "en_cours"/"terminee"), la boîte se masque normalement à la fin. // Silencieux (aucun effet) si la quête est introuvable ou si l'auteur n'a -// posé aucun widget sur l'écran — même esprit que le reste du moteur. +// posé aucun widget requis sur l'écran — même esprit que le reste du moteur. var forgeDialogueBoxState = {}; +var forgeQuizBoxState = {}; +window.forgeQuizScore = window.forgeQuizScore || 0; function forgeQuestById(questId) { return (gameData.quests || []).find(function (q) { return q.id === questId; }); } +function forgeEscapeHtml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// ---------- Boîte de dialogue ---------- + function forgeDialogueBoxWidgetOnCurrentScreen() { return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget'); } @@ -55,6 +80,57 @@ function forgeResetDialogueBoxFooter(widget, objectId) { if (footer) footer.innerHTML = ''; } +// ---------- Boîte à quiz ---------- + +function forgeQuizBoxWidgetOnCurrentScreen() { + return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget'); +} + +function forgeQuizBoxWidgetById(objectId) { + return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]'); +} + +function forgeHideQuizBoxOnCurrentScreen() { + var widget = forgeQuizBoxWidgetOnCurrentScreen(); + if (widget) widget.classList.remove('is-active'); +} + +// ---------- Score ---------- + +function forgeUpdateAllScoreWidgets() { + var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]'); + for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore; +} + +function forgeAddQuizScore(amount) { + window.forgeQuizScore += (amount || 0); + forgeUpdateAllScoreWidgets(); +} + +// ---------- Enchaînement dialogue/question ---------- + +// Affiche la ligne COURANTE de l'état de conversation `dialogueObjectId` +// (voir forgeDialogueBoxState) — une ligne "dialogue" dans la boîte de +// dialogue, une ligne "question" dans la boîte à quiz (voir +// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz +// n'est posée : impossible de bloquer la conversation indéfiniment). +function forgeShowCurrentConversationLine(dialogueObjectId) { + var state = forgeDialogueBoxState[dialogueObjectId]; + if (!state) return; + var line = state.lines[state.index]; + var widget = forgeDialogueBoxWidgetById(dialogueObjectId); + if (line.type === 'question') { + if (widget) widget.classList.remove('is-active'); + forgeShowQuizBox(dialogueObjectId, line); + return; + } + forgeHideQuizBoxOnCurrentScreen(); + if (!widget) return; + forgeResetDialogueBoxFooter(widget, dialogueObjectId); + forgeRenderDialogueBoxLine(widget, line); + widget.classList.add('is-active'); +} + // Appelée par l'action "quete" (collision-rules-controller.js) — trouve // le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois, // comme le reste du moteur 2D). @@ -65,39 +141,95 @@ function forgeShowDialogueBox(questId) { var widget = forgeDialogueBoxWidgetOnCurrentScreen(); if (!widget) return; var objectId = widget.getAttribute('data-object-id'); - // Aucune réplique posée pour ce statut : une quête "nouvelle" propose + // Aucune ligne posée pour ce statut : une quête "nouvelle" propose // quand même directement l'offre (accepter/refuser), les autres // statuts restent silencieux (rien à montrer). if (!lines.length) { if (quest.status === 'nouvelle') forgeShowQuestOffer(widget, objectId, quest); return; } - forgeResetDialogueBoxFooter(widget, objectId); forgeDialogueBoxState[objectId] = { questId: questId, lines: lines, index: 0 }; - forgeRenderDialogueBoxLine(widget, lines[0]); - widget.classList.add('is-active'); + forgeShowCurrentConversationLine(objectId); +} + +// Avance d'une ligne (fin de dialogue -> question, fin de question +// correcte -> ligne suivante) — appelée par le bouton "Suivant →" ET par +// une bonne réponse au quiz (voir forgeQuizBoxAnswer). +function forgeAdvanceConversation(dialogueObjectId) { + var state = forgeDialogueBoxState[dialogueObjectId]; + if (!state) return; + state.index += 1; + if (state.index >= state.lines.length) { + forgeHideQuizBoxOnCurrentScreen(); + var widget = forgeDialogueBoxWidgetById(dialogueObjectId); + var quest = forgeQuestById(state.questId); + if (quest && quest.status === 'nouvelle') { + if (widget) forgeShowQuestOffer(widget, dialogueObjectId, quest); + return; + } + // Tout le dialogue "en_cours" épuisé (donc, s'il contenait des + // questions, toutes répondues correctement) -> quête terminée. Voir + // demande utilisateur : "quand le joueur a répondu à toutes les + // questions la quête passe au statut terminé". + if (quest && quest.status === 'en_cours') quest.status = 'terminee'; + if (widget) widget.classList.remove('is-active'); + delete forgeDialogueBoxState[dialogueObjectId]; + return; + } + forgeShowCurrentConversationLine(dialogueObjectId); } // Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir -// render_scene_object.py, onclick="forgeDialogueBoxAdvance()"). +// render_scene_object.py, onclick="forgeDialogueBoxAdvance()") — sans +// effet pendant une question en cours (le "Suivant" n'existe pas alors, +// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité). function forgeDialogueBoxAdvance(objectId) { var state = forgeDialogueBoxState[objectId]; - var widget = forgeDialogueBoxWidgetById(objectId); - if (!state || !widget) return; - state.index += 1; - if (state.index >= state.lines.length) { - var quest = forgeQuestById(state.questId); - if (quest && quest.status === 'nouvelle') { - forgeShowQuestOffer(widget, objectId, quest); - return; - } - widget.classList.remove('is-active'); - delete forgeDialogueBoxState[objectId]; - return; - } - forgeRenderDialogueBoxLine(widget, state.lines[state.index]); + if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return; + forgeAdvanceConversation(objectId); } +// Affiche une question ("❓ Question") dans la boîte à quiz posée sur +// l'écran — si l'auteur n'en a posé aucune, la question est ignorée +// (avance directement) plutôt que de bloquer la conversation pour +// toujours en attendant une réponse qu'aucun widget ne peut recueillir. +function forgeShowQuizBox(dialogueObjectId, line) { + var widget = forgeQuizBoxWidgetOnCurrentScreen(); + if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; } + var quizObjectId = widget.getAttribute('data-object-id'); + forgeQuizBoxState[quizObjectId] = { + dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, + }; + var header = widget.querySelector('[data-quiz-role="header"]'); + var choicesEl = widget.querySelector('[data-quiz-role="choices"]'); + if (header) header.textContent = line.text; + if (choicesEl) { + choicesEl.innerHTML = line.choices.map(function (choice, i) { + return ''; + }).join(''); + } + widget.classList.add('is-active'); +} + +// Appelée par le clic sur un choix (voir render_scene_object.py côté +// éditeur — les vrais choix sont injectés EN JEU par forgeShowQuizBox +// ci-dessus, jamais rendus côté serveur). Mauvaise réponse : rien, la +// question reste affichée. Bonne réponse : crédite le score, masque la +// boîte, avance la conversation. +function forgeQuizBoxAnswer(quizObjectId, choiceIndex) { + var state = forgeQuizBoxState[quizObjectId]; + if (!state) return; + if (choiceIndex !== state.correctIndex) return; + forgeAddQuizScore(state.rewardAmount); + var widget = forgeQuizBoxWidgetById(quizObjectId); + if (widget) widget.classList.remove('is-active'); + var dialogueObjectId = state.dialogueObjectId; + delete forgeQuizBoxState[quizObjectId]; + forgeAdvanceConversation(dialogueObjectId); +} + +// ---------- Offre de quête (Accepter/Refuser) ---------- + // Écran d'offre : header = "Quête : ", body = l'objectif, pied = // Accepter/Refuser au lieu du "Suivant" habituel. function forgeShowQuestOffer(widget, objectId, quest) { @@ -143,5 +275,7 @@ if (typeof module !== 'undefined' && module.exports) { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine, forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, + forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, + forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, }; } diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index d4112e38..8637b9e3 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) { // se déplace lui-même. function forgeSolidObstacles(screenData, movingId) { return (screenData.elements || []).filter(function (o) { - return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false); + return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false); }); } diff --git a/static/js/play/screens.js b/static/js/play/screens.js index d7946cab..68dfedc6 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -57,6 +57,12 @@ 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; })); + // 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 + // joueur ait déjà marqué des points ailleurs resterait figé à "0" + // (valeur d'exemple rendue côté serveur) jusqu'au prochain point gagné. + if (typeof forgeUpdateAllScoreWidgets === 'function') forgeUpdateAllScoreWidgets(); } // Phase 6 — musique de fond par écran (voir screen-bg-music-url dans diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index 0f16fb83..3dffddf3 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -262,7 +262,7 @@ function questQuestionBubbleHtml(line, status, index) { '
' + '
' + '
' + - '' + '' + @@ -362,10 +362,15 @@ function questUpdateField(questId, field, value) { fetch('/game/' + GAME_SLUG + '/quests/' + questId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - .then(function (r) { return r.json(); }) - .then(function (json) { - if (!json.quest) return; - Object.assign(quest, json.quest); + .then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); }) + .then(function (res) { + // Refusé (400) : recompense_score abaissé sous le total déjà + // réparti entre les questions (voir routes/quests/quest_update.py) + // — message d'erreur demandé explicitement, et le champ est + // remis à sa valeur RÉELLE (inchangée côté serveur). + if (!res.ok) { alert(res.json.error || "Impossible d'enregistrer."); renderQuestFieldsModalBody(questId); return; } + if (!res.json.quest) return; + Object.assign(quest, res.json.quest); renderQuestListPage(); renderQuestFieldsModalBody(questId); // seule modale à porter des champs éditables }); @@ -385,7 +390,15 @@ function questUpdateField(questId, field, value) { function questSaveDialogues(quest) { fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }), - }); + }) + .then(function (r) { + // Refusé (400) : la somme des récompenses des questions dépasse la + // récompense totale de la quête (voir routes/quests/quest_dialogues.py) + // — message d'erreur demandé explicitement. La bulle en trop reste + // affichée localement (non enregistrée) : l'auteur baisse son + // montant ou celui d'une autre question pour repasser sous le budget. + if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); }); + }); } // Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de @@ -437,7 +450,7 @@ function questSetQuestionChoiceCount(status, index, value) { var quest = questById(_currentQuestModalId()); if (!quest) return; var line = quest.dialogues[status][index]; - var count = Math.max(2, Math.min(8, parseInt(value, 10) || 2)); + var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2)); 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; diff --git a/static/style.css b/static/style.css index 0ec232e7..d2a526ca 100644 --- a/static/style.css +++ b/static/style.css @@ -668,6 +668,34 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .dialogueBoxDeclineBtn{ background:rgba(224,82,77,.25); border-color:#e0524d; } .dialogueBoxDeclineBtn:hover{ background:rgba(224,82,77,.4); } +/* Widget "❓ Boîte à quiz" (voir screens/rendering/dialogue_box_style.py, + static/js/play/dialogue-box-controller.js) : même squelette que + "💬 Boîte de dialogue" (header/corps, même "🎨 Style") mais pas de pied + — la question se résout au clic sur un choix. Masquée par défaut au + jeu, comme la boîte de dialogue. */ +.quizBoxWidget{ + display:flex; flex-direction:column; border-radius:10px; overflow:hidden; + box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto; +} +.playScreen .quizBoxWidget{ display:none; } +.playScreen .quizBoxWidget.is-active{ display:flex; } +.quizBoxHeader{ padding:8px 12px; font-weight:700; } +.quizBoxChoices{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:8px; } +.quizBoxChoiceBtn{ + background:rgba(255,255,255,.12); color:inherit; border:1px solid rgba(255,255,255,.3); + border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; text-align:left; cursor:pointer; + transition:background .15s ease, transform .1s ease; +} +.quizBoxChoiceBtn:hover{ background:rgba(255,255,255,.25); transform:translateX(2px); } + +/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/ + déclencheur, contrairement aux deux widgets ci-dessus), voir + forgeUpdateAllScoreWidgets(). */ +.scoreWidget{ + display:flex; align-items:center; justify-content:center; gap:4px; + border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:none; +} + /* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur sa propre balise (voir applySelectionHighlight) — seul moyen de la voir sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/ diff --git a/templates/play.html b/templates/play.html index 63c58335..b096ddc5 100644 --- a/templates/play.html +++ b/templates/play.html @@ -84,13 +84,13 @@ uniquement si un objet kind="fond" plus grand que la scène est posé (caméra qui suit le personnage). -->
- {% for obj in s.elements if obj.kind != 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %} + {% for obj in s.elements if obj.kind not in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
+ .sceneWorld ci-dessus (voir static/style.css) — "💬 Boîte de + dialogue"/"❓ Boîte à quiz"/"🏆 Score" (screens/rendering/dialogue_box_style.py). -->
- {% for obj in s.elements if obj.kind == 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %} + {% for obj in s.elements if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
{% else %} diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 871ebcbe..90aa00c9 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -17,7 +17,9 @@ .canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } /* Même correctif que ci-dessus, pour le widget "💬 Boîte de dialogue" (screens/scenes/render_scene_object.py, un
, pas un ). */ - .canvasElementInner > .dialogueBoxWidget{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } + .canvasElementInner > .dialogueBoxWidget, .canvasElementInner > .quizBoxWidget, .canvasElementInner > .scoreWidget{ + position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; + } {% endblock %} {% block content %} @@ -100,8 +102,10 @@
🖥️ Interface
- -

Affiche automatiquement le dialogue de la quête en cours (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posée.

+ + + +

Boîte de dialogue/quiz : gérées automatiquement par le moteur (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posées. Score : affiche en continu les points gagnés.

@@ -124,7 +128,7 @@ onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
{{ o.rendered_html|safe }}
- {% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box') %} + {% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %} + {% if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %} +
🎨 Style
@@ -373,7 +380,7 @@
{% endif %} - {% if obj.kind not in ('fond', 'dialogue_box') %} + {% if obj.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %} +
+ {% endif %} {% for o in objects %}
le monde affiché reste + exactement la scène nominale, et le repère "🎥 Champ de la caméra" + (voir static/style.css .sceneCameraFrame) ne s'affiche pas — inutile + tant que rien ne dépasse.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) + assert 'id="canvas" style="width:960px; height:540px;' in html + assert "sceneCameraFrame" not in html + + +def test_scene_edit_view_expands_world_for_an_oversized_fond(client, tmp_game_slug_cleanup): + """Bug corrigé (glisser-déposer figé à (0,0) pour un objet plus grand + que la scène, voir static/js/scenes/scene-editor.js) : le canevas de + l'éditeur doit maintenant afficher tout le "fond", pas seulement la + scène nominale (960×540) qu'il dépasse — sinon la partie qui dépasse + resterait invisible/injoignable à la souris malgré le fix du glisser.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + fond_id = screens.add_scene_object(slug, screen_id, kind="fond") + screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080) + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) + assert 'id="canvas" style="width:1920px; height:1080px;' in html + # Le repère de la caméra marque toujours les bornes de la scène NOMINALE (960×540), + # même si le canevas affiché est désormais bien plus grand. + assert 'class="sceneCameraFrame" style="width:960px; height:540px;' in html + + +def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + fond_id = screens.add_scene_object(slug, screen_id, kind="fond") + screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080) + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) + assert "var SCENE_WIDTH = 1920;" in html + assert "var SCENE_HEIGHT = 1080;" in html From b9d3c460874f5c27affed75bb31156e0ed87c884 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 17:07:24 +0200 Subject: [PATCH 19/23] =?UTF-8?q?Bouton=20"=F0=9F=93=90=20Agrandir=20la=20?= =?UTF-8?q?cam=C3=A9ra=20=C3=A0=20la=20zone=20visible"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fait de tout le "monde" affiché par l'éditeur (la taille réelle d'un "fond" plus grand que la scène nominale, voir routes/scenes/ scene_edit_view.py::world_width/world_height) la scène/caméra ELLE-MÊME — jusqu'ici scene_width/scene_height n'étaient fixées qu'à la création de l'écran, jamais modifiables ensuite. Le bouton n'apparaît que quand un fond dépasse encore la scène nominale (même condition que le repère "🎥 Champ de la caméra"), affiche la taille cible, recharge la page une fois appliqué. La caméra ne recadre alors plus rien en jeu (monde == scène == viewport, voir personnage-controller.js::forgeUpdateSceneCamera). Co-Authored-By: Claude Sonnet 5 --- routes/screens/__init__.py | 2 +- routes/screens/screen_set_scene_size.py | 20 ++++++++++++++ screens/__init__.py | 1 + screens/screens_repo/set_scene_size.py | 19 ++++++++++++++ static/js/scenes/scene-editor.js | 14 ++++++++++ templates/scene_edit.html | 12 +++++++-- tests/test_scene_edit_view.py | 35 +++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 routes/screens/screen_set_scene_size.py create mode 100644 screens/screens_repo/set_scene_size.py diff --git a/routes/screens/__init__.py b/routes/screens/__init__.py index 6912fe9e..8fd2d9f4 100644 --- a/routes/screens/__init__.py +++ b/routes/screens/__init__.py @@ -1 +1 @@ -from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music +from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music, screen_set_scene_size diff --git a/routes/screens/screen_set_scene_size.py b/routes/screens/screen_set_scene_size.py new file mode 100644 index 00000000..3466ccf1 --- /dev/null +++ b/routes/screens/screen_set_scene_size.py @@ -0,0 +1,20 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//scene-size", methods=["POST"]) +def screen_set_scene_size(slug, screen_id): + """"📐 Agrandir la caméra à la zone visible" (voir templates/ + scene_edit.html) — redimensionne la scène/caméra elle-même à la + taille du "monde" affiché (world_width/world_height, voir + routes/scenes/scene_edit_view.py), calculée et postée par le client.""" + body = request.get_json(silent=True) or {} + try: + width, height = int(body.get("width")), int(body.get("height")) + except (TypeError, ValueError): + return jsonify({"error": "largeur/hauteur invalides"}), 400 + screens.set_scene_size(slug, screen_id, width, height) + return jsonify({"ok": True}) diff --git a/screens/__init__.py b/screens/__init__.py index 246e62a0..fb9463d1 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -100,6 +100,7 @@ from .screens_repo.delete_screen import delete_screen from .screens_repo.move_screen import move_screen from .screens_repo.screens_referencing import screens_referencing from .screens_repo.set_screen_aspect import set_screen_aspect +from .screens_repo.set_scene_size import set_scene_size from .screens_repo.set_screen_background_music import set_screen_background_music from .elements.list_elements import list_elements diff --git a/screens/screens_repo/set_scene_size.py b/screens/screens_repo/set_scene_size.py new file mode 100644 index 00000000..4096dad9 --- /dev/null +++ b/screens/screens_repo/set_scene_size.py @@ -0,0 +1,19 @@ +import db + + +def set_scene_size(slug, screen_id, width, height): + """Redimensionne la scène/caméra elle-même (colonnes scene_width/ + scene_height, voir ensure_schema.py) — jusqu'ici fixées à la création + et jamais modifiables ensuite. Utilisé par "📐 Agrandir la caméra à la + zone visible" (templates/scene_edit.html) : quand un "fond" plus + grand que la scène nominale l'a fait déborder dans le "monde" affiché + par l'éditeur (voir routes/scenes/scene_edit_view.py::world_width/ + world_height), ce bouton fait de CE monde la scène elle-même — la + caméra ne recadre alors plus rien (voir personnage-controller.js:: + forgeUpdateSceneCamera, sans effet dès que monde == scène).""" + width = max(1, int(width)) + height = max(1, int(height)) + conn = db.connect(slug) + conn.execute("UPDATE _screens SET scene_width = ?, scene_height = ? WHERE id = ?", (width, height, screen_id)) + conn.commit() + conn.close() diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index c14a1c28..9fe057dc 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -20,6 +20,20 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) { form.requestSubmit(); } +// "📐 Agrandir la caméra à la zone visible" (voir templates/scene_edit.html) : +// fait de tout le "monde" affiché par l'éditeur (voir world_width/ +// world_height, routes/scenes/scene_edit_view.py — la taille réelle +// d'un "fond" plus grand que la scène nominale) la scène/caméra +// elle-même — recharge la page ensuite, SCENE_WIDTH/SCENE_HEIGHT et le +// cadre "🎥 Champ de la caméra" doivent tous deux refléter la nouvelle +// taille, plus simple qu'une mise à jour partielle en JS. +function expandCameraToWorld(width, height) { + fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-size", { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ width: width, height: height }), + }).then(function () { window.location.reload(); }); +} + function saveSceneObjectGeometry(id, x, y, width, height) { return fetch("/game/" + GAME_SLUG + "/scene-objects/" + id + "/geometry", { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 270308d1..fefc770c 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -29,8 +29,16 @@ tel quel pour l'éditeur document) a été retiré de CET éditeur à la demande de l'utilisateur, en prévision d'un éditeur dédié aux scènes 2D (collision/périmètre -> action), pas encore construit. --> -

- 🎮 Jeu 2D — scène à taille fixe ({{ screen.scene_width }}×{{ screen.scene_height }} px). +

+ 🎮 Jeu 2D — scène à taille fixe ({{ screen.scene_width }}×{{ screen.scene_height }} px). + {% if world_width > screen.scene_width or world_height > screen.scene_height %} + + + {% endif %}

diff --git a/tests/test_scene_edit_view.py b/tests/test_scene_edit_view.py index f355f82f..b653fd93 100644 --- a/tests/test_scene_edit_view.py +++ b/tests/test_scene_edit_view.py @@ -239,3 +239,38 @@ def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_gam html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert "var SCENE_WIDTH = 1920;" in html assert "var SCENE_HEIGHT = 1080;" in html + + +def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup): + """"📐 Agrandir la caméra à la zone visible" (voir templates/ + scene_edit.html) : fait du "monde" affiché la scène/caméra + elle-même — jusqu'ici fixée à la création et jamais modifiable.""" + slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_size")) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + screens.set_scene_size(slug, screen_id, 1920, 1080) + screen = screens.get_screen(slug, screen_id) + assert screen["scene_width"] == 1920 + assert screen["scene_height"] == 1080 + + +def test_screen_set_scene_size_route_persists_and_reflects_in_editor(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}/scene-size", + data=json.dumps({"width": 1920, "height": 1080}), content_type="application/json", + ) + assert resp.get_json() == {"ok": True} + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) + assert 'id="canvas" style="width:1920px; height:1080px;' in html + # La scène nominale == le monde désormais -> plus de repère caméra ni de bouton. + assert "sceneCameraFrame" not in html + assert "Agrandir la caméra" not in html + + +def test_screen_set_scene_size_route_rejects_invalid_dimensions(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}/scene-size", + data=json.dumps({"width": "oops", "height": 540}), content_type="application/json", + ) + assert resp.status_code == 400 From 55c6bb330b0487a3b2a13752cefe8369f1c1a699 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 17:28:03 +0200 Subject: [PATCH 20/23] =?UTF-8?q?Contr=C3=B4le=20direct=20de=20la=20taille?= =?UTF-8?q?=20de=20la=20cam=C3=A9ra=20(Largeur=20=C3=97=20Hauteur,=20toujo?= =?UTF-8?q?urs=20modifiable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le bouton conditionnel "Agrandir la caméra à la zone visible" (n'apparaissait que si un fond dépassait déjà la scène) par deux champs "Caméra (px)" TOUJOURS visibles, pré-remplis avec scene_width/ scene_height actuels — demande explicite : "l'écran de l'éditeur a une taille fixe, la caméra a la même taille, c'est tout", un réglage direct plutôt qu'une suggestion automatique liée à la taille d'un fond. Co-Authored-By: Claude Sonnet 5 --- static/js/scenes/scene-editor.js | 18 ++++++++++-------- templates/scene_edit.html | 24 +++++++++++++++--------- tests/test_scene_edit_view.py | 19 ++++++++++++++----- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index 9fe057dc..3110058d 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -20,14 +20,16 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) { form.requestSubmit(); } -// "📐 Agrandir la caméra à la zone visible" (voir templates/scene_edit.html) : -// fait de tout le "monde" affiché par l'éditeur (voir world_width/ -// world_height, routes/scenes/scene_edit_view.py — la taille réelle -// d'un "fond" plus grand que la scène nominale) la scène/caméra -// elle-même — recharge la page ensuite, SCENE_WIDTH/SCENE_HEIGHT et le -// cadre "🎥 Champ de la caméra" doivent tous deux refléter la nouvelle -// taille, plus simple qu'une mise à jour partielle en JS. -function expandCameraToWorld(width, height) { +// Taille de la caméra (voir "🎮 Jeu 2D" en haut de templates/ +// scene_edit.html) — TOUJOURS modifiable ici, directement en pixels +// (demande explicite : "l'écran de l'éditeur a une taille fixe, la +// caméra a la même taille, c'est tout") : recharge la page une fois +// appliqué, SCENE_WIDTH/SCENE_HEIGHT et le cadre "🎥 Champ de la caméra" +// éventuel doivent tous deux refléter la nouvelle taille, plus simple +// qu'une mise à jour partielle en JS. +function applyCameraSize() { + var wEl = document.getElementById('scene-camera-w'), hEl = document.getElementById('scene-camera-h'); + var width = parseInt(wEl.value, 10) || 1, height = parseInt(hEl.value, 10) || 1; fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-size", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ width: width, height: height }), diff --git a/templates/scene_edit.html b/templates/scene_edit.html index fefc770c..c5d51b7e 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -30,15 +30,21 @@ demande de l'utilisateur, en prévision d'un éditeur dédié aux scènes 2D (collision/périmètre -> action), pas encore construit. -->

- 🎮 Jeu 2D — scène à taille fixe ({{ screen.scene_width }}×{{ screen.scene_height }} px). - {% if world_width > screen.scene_width or world_height > screen.scene_height %} - - - {% endif %} + 🎮 Jeu 2D — scène à taille fixe. + + + + +

diff --git a/tests/test_scene_edit_view.py b/tests/test_scene_edit_view.py index b653fd93..8a1e0781 100644 --- a/tests/test_scene_edit_view.py +++ b/tests/test_scene_edit_view.py @@ -214,6 +214,12 @@ def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(client, tmp_ html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert 'id="canvas" style="width:960px; height:540px;' in html assert "sceneCameraFrame" not in html + # "Caméra (px)" : TOUJOURS modifiable, même sans fond qui dépasse + # (demande explicite : "l'écran de l'éditeur a une taille fixe, la + # caméra a la même taille, c'est tout" — un réglage direct, pas + # seulement suggéré automatiquement). + assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="960"' in html + assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="540"' in html def test_scene_edit_view_expands_world_for_an_oversized_fond(client, tmp_game_slug_cleanup): @@ -242,9 +248,9 @@ def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_gam def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup): - """"📐 Agrandir la caméra à la zone visible" (voir templates/ - scene_edit.html) : fait du "monde" affiché la scène/caméra - elle-même — jusqu'ici fixée à la création et jamais modifiable.""" + """"Caméra (px)" (voir templates/scene_edit.html) : jusqu'ici + scene_width/scene_height n'étaient fixées qu'à la création de + l'écran, jamais modifiables ensuite.""" slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_size")) screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") screens.set_scene_size(slug, screen_id, 1920, 1080) @@ -262,9 +268,12 @@ def test_screen_set_scene_size_route_persists_and_reflects_in_editor(client, tmp assert resp.get_json() == {"ok": True} html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert 'id="canvas" style="width:1920px; height:1080px;' in html - # La scène nominale == le monde désormais -> plus de repère caméra ni de bouton. + # La scène nominale == le monde désormais -> plus de repère caméra. assert "sceneCameraFrame" not in html - assert "Agrandir la caméra" not in html + # Les champs "Caméra (px)" restent TOUJOURS visibles (pas conditionnels), + # pré-remplis avec la nouvelle taille. + assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="1920"' in html + assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="1080"' in html def test_screen_set_scene_size_route_rejects_invalid_dimensions(client, tmp_game_slug_cleanup): From 413685e871f7d8c95281096b7e15bac4c6e93df0 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 17:56:37 +0200 Subject: [PATCH 21/23] =?UTF-8?q?Relie=20le=20score/statut=20des=20qu?= =?UTF-8?q?=C3=AAtes=20aux=20indicateurs=20SCORM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les quêtes/quiz mettaient à jour l'état local (statut, score) sans jamais toucher gameData.scoring, que scorm-api.js interroge déjà pour reporter Completion/Success/Score au LMS. Ajoute forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm et forgeSyncAllQuestsCompletionToScorm, appelées lors d'un bon score, de l'acceptation d'une quête et de la complétion de toutes les quêtes. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/dialogue-box-controller.test.js | 73 +++++++++++++++++++ static/js/play/dialogue-box-controller.js | 43 ++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index 15496380..5995a68c 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -12,6 +12,7 @@ const { forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets, + forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm, } = require('../dialogue-box-controller.js'); function fakeWidget(objectId) { @@ -321,3 +322,75 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a assert.equal(values[1].textContent, 42); } finally { global.document = previousDoc; } }); + +// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ---------- +// Bug signalé : "les indicateurs qu'on utilise pour les quêtes devraient +// aussi servir aux indicateurs SCORM, or ce n'est pas le cas" — un jeu +// construit uniquement avec des quêtes/quiz ne touchait jamais +// gameData.scoring, laissant Score/Completion perpétuellement "unknown" +// dans le LMS même après avoir fini le jeu. + +test('forgeAddQuizScore alimente aussi gameData.scoring (score + statut "en_cours")', () => { + global.gameData.scoring = undefined; + window.forgeQuizScore = 0; + const previousDoc = global.document; + global.document = { querySelectorAll: () => [] }; + try { + forgeAddQuizScore(5); + assert.equal(gameData.scoring.score, 5); + assert.equal(gameData.scoring.status, 'en_cours'); + + forgeAddQuizScore(3); + assert.equal(gameData.scoring.score, 8); // cumulé, jamais écrasé + assert.equal(gameData.scoring.status, 'en_cours'); // déjà "en_cours", pas rétrogradé + } finally { global.document = previousDoc; } +}); + +test('forgeAddQuizScore ne rétrograde jamais un statut déjà plus avancé que "non_commence"', () => { + global.gameData.scoring = { score: 0, status: 'termine' }; + window.forgeQuizScore = 0; + const previousDoc = global.document; + global.document = { querySelectorAll: () => [] }; + try { + forgeAddQuizScore(2); + assert.equal(gameData.scoring.status, 'termine'); + } finally { global.document = previousDoc; } +}); + +test('accepter une quête fait passer gameData.scoring.status à "en_cours" (la partie a commencé)', () => { + const widget = fakeWidget(42); + global.gameData.scoring = undefined; + const previousDoc = global.document; + global.document = { querySelector: () => widget }; + try { + forgeDialogueBoxAcceptQuest(5, 42); + assert.equal(gameData.scoring.status, 'en_cours'); + } finally { global.document = previousDoc; } +}); + +test('forgeSyncAllQuestsCompletionToScorm — passe "termine" seulement quand TOUTES les quêtes sont "terminee"', () => { + global.gameData.quests = [{ id: 1, status: 'terminee' }, { id: 2, status: 'en_cours' }]; + global.gameData.scoring = { score: 0, status: 'en_cours' }; + forgeSyncAllQuestsCompletionToScorm(); + assert.equal(gameData.scoring.status, 'en_cours'); // pas encore toutes finies + + gameData.quests[1].status = 'terminee'; + forgeSyncAllQuestsCompletionToScorm(); + assert.equal(gameData.scoring.status, 'termine'); +}); + +test('la dernière ligne d\'un dialogue "en_cours" propage la complétion à gameData.scoring si c\'est la seule quête', () => { + const widget = fakeWidget(42); + global.gameData.quests = [ + { id: 20, status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ speaker: 'PNJ', text: 'Bravo !' }], terminee: [] } }, + ]; + global.gameData.scoring = { score: 0, status: 'en_cours' }; + const previousDoc = global.document; + global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) }; + try { + forgeShowDialogueBox(20); + forgeDialogueBoxAdvance(42); // dernière (et unique) ligne consommée -> quête "terminee" + assert.equal(forgeQuestById(20).status, 'terminee'); + assert.equal(gameData.scoring.status, 'termine'); + } finally { global.document = previousDoc; } +}); diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js index 781ff9ec..0748c711 100644 --- a/static/js/play/dialogue-box-controller.js +++ b/static/js/play/dialogue-box-controller.js @@ -102,9 +102,45 @@ function forgeUpdateAllScoreWidgets() { for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore; } +// Alimente aussi gameData.scoring (voir static/js/play/offline/ +// apply-actions.js, même forme {score, status}) — bug signalé : "les +// indicateurs qu'on utilise pour les quêtes devraient aussi servir aux +// indicateurs SCORM, or ce n'est pas le cas". C'est CE champ que +// static/js/play/offline/scorm-api.js lit toutes les 2s pour pousser +// cmi.core.score.raw/cmi.core.lesson_status au LMS — sans jamais y +// toucher, un jeu construit uniquement avec des quêtes/quiz (jamais +// l'action de flow "Modifier un score") laissait Score/Completion +// perpétuellement "unknown" dans SCORM Cloud, même après y avoir joué +// jusqu'au bout. Vocabulaire de statut identique à db/scoring/ +// (non_commence/en_cours/termine/...), voir FORGE_TO_SCORM_STATUS. +function forgeSyncQuizScoreToScorm() { + gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; + gameData.scoring.score = window.forgeQuizScore; + if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours'; +} + +// Une quête acceptée = la partie a commencé, même sans le moindre point +// encore gagné — sans cet appel, Score restait "0" mais Completion +// restait "unknown" tant qu'aucune question n'avait encore été résolue. +function forgeSyncQuestStartedToScorm() { + gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; + if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours'; +} + +// Le jeu entier est considéré terminé quand TOUTES les quêtes connues +// sont "terminee" — règle simple et prévisible plutôt que de deviner +// laquelle des quêtes créées "compte" pour la fin du jeu. +function forgeSyncAllQuestsCompletionToScorm() { + var quests = gameData.quests || []; + if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return; + gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' }; + gameData.scoring.status = 'termine'; +} + function forgeAddQuizScore(amount) { window.forgeQuizScore += (amount || 0); forgeUpdateAllScoreWidgets(); + forgeSyncQuizScoreToScorm(); } // ---------- Enchaînement dialogue/question ---------- @@ -171,7 +207,10 @@ function forgeAdvanceConversation(dialogueObjectId) { // questions, toutes répondues correctement) -> quête terminée. Voir // demande utilisateur : "quand le joueur a répondu à toutes les // questions la quête passe au statut terminé". - if (quest && quest.status === 'en_cours') quest.status = 'terminee'; + if (quest && quest.status === 'en_cours') { + quest.status = 'terminee'; + forgeSyncAllQuestsCompletionToScorm(); + } if (widget) widget.classList.remove('is-active'); delete forgeDialogueBoxState[dialogueObjectId]; return; @@ -287,6 +326,7 @@ function forgeShowQuestOffer(widget, objectId, quest) { function forgeDialogueBoxAcceptQuest(questId, objectId) { var quest = forgeQuestById(questId); if (quest) quest.status = 'en_cours'; + forgeSyncQuestStartedToScorm(); var widget = forgeDialogueBoxWidgetById(objectId); if (widget) widget.classList.remove('is-active'); delete forgeDialogueBoxState[objectId]; @@ -310,5 +350,6 @@ if (typeof module !== 'undefined' && module.exports) { forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter, forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine, + forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm, }; } From 00f7191a8b587a311fc81c7689cff4d1ca2af6bf Mon Sep 17 00:00:00 2001 From: william Date: Thu, 3 Sep 2026 18:23:17 +0200 Subject: [PATCH 22/23] =?UTF-8?q?Corrige=20les=20animations=20fig=C3=A9es?= =?UTF-8?q?=20et=20le=20reporting=20SCORM=20dans=20le=20paquet=20export?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux bugs distincts détectés dans le paquet Web/SCORM : - L'animation de marche du personnage dépend entièrement des touches clavier maintenues (heldKeys), qui ne sont alimentées que par des écouteurs keydown/keyup sur window. Un LMS comme SCORM Cloud charge index.html dans une iframe qui ne reçoit pas le focus clavier par défaut : aucune touche n'atteignait jamais window, donc le personnage ne bougeait/animait jamais. On force désormais le focus au chargement et on le reprend au premier clic dans le jeu. - scorm-api.js (qui pousse score/statut au LMS) était bien copié dans le zip mais jamais référencé par un From 50835a18e23d7a83c4f58bb5f460b1b27cd9f479 Mon Sep 17 00:00:00 2001 From: william Date: Fri, 4 Sep 2026 20:56:32 +0200 Subject: [PATCH 23/23] =?UTF-8?q?Supprime=20le=20type=20d'=C3=A9cran=20"do?= =?UTF-8?q?cument"=20et=20recentre=20le=20produit=20sur=20le=20jeu=202D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées. --- .gitignore | 4 + app.py | 10 +- core/jinja_filters.py | 6 - db/games/game_type_catalog.py | 65 +- db/games/get_game_type.py | 2 +- filters/element_style_filter.py | 76 - routes/__init__.py | 2 +- routes/element_types/__init__.py | 1 - routes/element_types/element_type_delete.py | 12 - routes/element_types/element_type_edit.py | 16 - routes/element_types/element_types.py | 22 - routes/elements/__init__.py | 14 - routes/elements/element_add.py | 37 - routes/elements/element_add_child.py | 25 - routes/elements/element_delete.py | 22 - routes/elements/element_delete_impact.py | 16 - routes/elements/element_duplicate.py | 14 - routes/elements/element_geometry.py | 20 - routes/elements/element_move_to_container.py | 21 - routes/elements/element_rename.py | 14 - routes/elements/element_reorder.py | 22 - routes/elements/element_save.py | 18 - .../elements/element_set_personnage_data.py | 24 - routes/elements/element_tab_add.py | 14 - routes/games/game_dashboard.py | 85 +- routes/legacy_actions/__init__.py | 1 - routes/legacy_actions/action_add.py | 35 - routes/legacy_actions/action_delete.py | 15 - routes/legacy_actions/action_run_data.py | 17 - routes/objects/__init__.py | 14 - routes/objects/data_delete.py | 21 - routes/objects/data_edit.py | 17 - routes/objects/data_form.py | 24 - routes/objects/data_new.py | 7 - routes/objects/object_delete.py | 19 - routes/objects/object_edit.py | 24 - routes/objects/object_field_add.py | 26 - routes/objects/object_field_delete.py | 11 - routes/objects/object_field_edit.py | 20 - routes/objects/object_new.py | 35 - routes/objects/object_view.py | 18 - routes/objects/parse_field_rows.py | 36 - routes/scenes/__init__.py | 2 + routes/scenes/collision_rule_objects.py | 61 + routes/scenes/scene_collision_rule_objects.py | 24 + routes/scenes/scene_edit_view.py | 46 +- routes/scenes/scene_object_add.py | 32 +- routes/scenes/scene_object_delete.py | 17 +- routes/scenes/scene_object_names_list.py | 20 + routes/screens/__init__.py | 2 +- routes/screens/screen_edit.py | 128 +- routes/screens/screen_set_aspect.py | 17 - routes/screens/screens_new.py | 15 +- screens/__init__.py | 77 +- .../data_definition_options.py | 0 screens/element_types/__init__.py | 1 - screens/element_types/create_element_type.py | 21 - screens/element_types/delete_element_type.py | 15 - .../element_types/element_type_usage_count.py | 10 - .../get_element_type_by_template_screen.py | 16 - screens/element_types/is_overlay_only.py | 16 - screens/element_types/list_element_types.py | 19 - screens/element_types/load_template_tree.py | 49 - screens/element_types/update_element_type.py | 13 - screens/elements/__init__.py | 1 - screens/elements/add_element.py | 87 -- screens/elements/add_tab.py | 19 - screens/elements/delete_element.py | 50 - screens/elements/duplicate_element.py | 60 - screens/elements/element_descendant_ids.py | 23 - screens/elements/element_label.py | 10 - .../flow_nodes_referencing_element.py | 30 - screens/elements/get_element.py | 15 - screens/elements/list_elements.py | 95 -- screens/elements/move_element_to_container.py | 62 - screens/elements/rename_element.py | 8 - screens/elements/reorder_element.py | 37 - screens/elements/save_element_controls.py | 227 --- screens/elements/set_personnage_data.py | 20 - screens/elements/update_element_geometry.py | 14 - screens/flow/ensure_flow_schema.py | 4 +- screens/labels/element_action_properties.py | 70 +- screens/legacy_actions/__init__.py | 2 - screens/legacy_actions/add_action.py | 25 - screens/legacy_actions/delete_action.py | 8 - screens/legacy_actions/get_action.py | 8 - screens/legacy_actions/list_actions.py | 8 - screens/payload/full_game_payload.py | 134 +- screens/rendering/apply_ctx.py | 14 - screens/rendering/attr_string.py | 10 - screens/rendering/auto_id.py | 2 - screens/rendering/filter_repeater_rows.py | 199 --- .../rendering/list_used_forge_characters.py | 10 +- screens/rendering/parent_flex_direction.py | 9 - screens/rendering/render_checkbox_or_radio.py | 19 - screens/rendering/render_children.py | 13 - screens/rendering/render_element_html.py | 220 --- screens/rendering/render_fieldset.py | 15 - screens/rendering/render_icone.py | 24 - screens/rendering/render_jauge.py | 164 -- screens/rendering/render_onglets.py | 66 - screens/rendering/render_overlay.py | 100 -- screens/rendering/render_personnage.py | 21 - screens/rendering/render_repeater.py | 79 - screens/rendering/render_select.py | 31 - screens/rendering/render_table.py | 21 - screens/rendering/resolve_bound_row.py | 36 - screens/rendering/style_string.py | 80 - screens/rendering/visibility_condition.py | 69 - screens/rendering/visible_attrs.py | 34 - screens/scenes/add_scene_object.py | 12 +- screens/scenes/resolve_scene_world_size.py | 17 + screens/screens_repo/create_screen.py | 2 +- screens/screens_repo/ensure_schema.py | 93 +- screens/screens_repo/screens_referencing.py | 15 +- screens/screens_repo/set_screen_aspect.py | 20 - screens/widgets/__init__.py | 1 - screens/widgets/border_style_options.py | 1 - screens/widgets/bulma_variants.py | 14 - screens/widgets/container_widgets.py | 1 - screens/widgets/control_groups/__init__.py | 1 - .../widgets/control_groups/border_controls.py | 20 - .../widgets/control_groups/bulma_controls.py | 65 - .../control_groups/data_binding_controls.py | 20 - .../widgets/control_groups/hover_controls.py | 21 - .../widgets/control_groups/layout_controls.py | 44 - .../widgets/control_groups/shadow_controls.py | 5 - .../control_groups/size_override_controls.py | 29 - .../control_groups/spacing_controls.py | 20 - .../text_style_controls_extra.py | 6 - .../control_groups/universal_controls.py | 16 - .../visibility_condition_controls.py | 34 - .../control_groups/visibility_control.py | 16 - screens/widgets/control_value.py | 95 -- screens/widgets/controls/__init__.py | 1 - screens/widgets/controls/c_align.py | 2 - screens/widgets/controls/c_checkbox.py | 2 - screens/widgets/controls/c_clause_list.py | 18 - screens/widgets/controls/c_color.py | 2 - screens/widgets/controls/c_preset.py | 3 - screens/widgets/controls/c_scale.py | 2 - screens/widgets/controls/c_select.py | 2 - screens/widgets/controls/c_shadow.py | 12 - screens/widgets/controls/c_size.py | 5 - screens/widgets/controls/c_slider.py | 11 - screens/widgets/controls/c_text.py | 2 - screens/widgets/controls/c_textarea.py | 2 - screens/widgets/controls/c_toggle_token.py | 2 - screens/widgets/controls/c_url.py | 3 - screens/widgets/controls_with_values.py | 99 -- screens/widgets/default_style_for_widget.py | 123 -- screens/widgets/font_options.py | 10 - screens/widgets/fontawesome_icons.py | 47 - screens/widgets/layout_capable_widgets.py | 8 - screens/widgets/registry.py | 353 ----- screens/widgets/widget_meta.py | 14 - static/js/quests/quest-editor.js | 61 +- static/js/scenes/collision-rules-editor.js | 75 +- static/js/scenes/scene-editor.js | 200 ++- static/js/screen_edit/animation-timeline.js | 404 ----- static/js/screen_edit/flow-editor.js | 875 ----------- static/js/screen_edit/panel-init.js | 12 +- static/js/screen_edit/tabs-and-blocks.js | 119 -- static/js/screen_edit/tree-panels.js | 18 +- static/style.css | 132 +- templates/base.html | 29 +- templates/game_dashboard.html | 1150 -------------- templates/game_dashboard_simple.html | 19 +- templates/onboarding/onboarding_new.html | 6 +- templates/partials/clause_row.html | 2 +- templates/play.html | 15 +- templates/scene_edit.html | 113 +- templates/screen_edit.html | 1330 ----------------- tests/conftest.py | 23 +- tests/test_add_row_action.py | 14 +- tests/test_animal_sprite_library.py | 40 - tests/test_animations.py | 28 +- tests/test_auth.py | 5 +- tests/test_bornage.py | 17 +- tests/test_collision_rules.py | 9 +- tests/test_condition_variable.py | 31 +- tests/test_conditions_combinees.py | 19 +- tests/test_confort.py | 474 ------ tests/test_custom_events.py | 15 +- tests/test_data_binding_clause_list.py | 150 -- tests/test_data_binding_refresh_marker.py | 100 -- tests/test_data_form_id_collision.py | 42 - .../test_delete_element_referenced_by_flow.py | 104 -- tests/test_element_type_bound_field_picker.py | 76 - tests/test_element_type_live_instances.py | 121 -- tests/test_export_scorm.py | 104 -- tests/test_filter_value_global_variable.py | 95 -- tests/test_flow.py | 123 +- tests/test_flow_blocks.py | 28 +- tests/test_flow_clicked_row.py | 24 +- tests/test_game_type.py | 33 +- tests/test_jauge.py | 272 ---- tests/test_keyboard_and_timer_triggers.py | 11 +- tests/test_object_new_without_fields.py | 71 - tests/test_onboarding.py | 90 +- tests/test_onglets.py | 43 - tests/test_onglets_widget.py | 124 -- tests/test_personnage_role.py | 22 +- tests/test_personnage_widget.py | 151 -- tests/test_player_state.py | 10 - tests/test_position_and_collision.py | 103 +- .../test_reserved_sql_keyword_field_names.py | 74 - tests/test_scene_collision_rule_objects.py | 75 + tests/test_scene_edit_view.py | 39 +- tests/test_scene_object_add_ajax.py | 40 + tests/test_scene_object_delete_ajax.py | 53 + tests/test_scene_object_names_list.py | 35 + tests/test_scoring.py | 6 +- tests/test_screen_layout_fixes.py | 142 -- tests/test_screens_and_elements.py | 278 ---- tests/test_shadow_controls.py | 102 -- tests/test_sound.py | 19 +- tests/test_sprite_animation.py | 42 +- tests/test_variable_object_array.py | 121 -- tests/test_visibility_condition.py | 181 --- tests/test_visibility_toggle_markers.py | 98 -- 221 files changed, 1239 insertions(+), 12245 deletions(-) delete mode 100644 filters/element_style_filter.py delete mode 100644 routes/element_types/__init__.py delete mode 100644 routes/element_types/element_type_delete.py delete mode 100644 routes/element_types/element_type_edit.py delete mode 100644 routes/element_types/element_types.py delete mode 100644 routes/elements/__init__.py delete mode 100644 routes/elements/element_add.py delete mode 100644 routes/elements/element_add_child.py delete mode 100644 routes/elements/element_delete.py delete mode 100644 routes/elements/element_delete_impact.py delete mode 100644 routes/elements/element_duplicate.py delete mode 100644 routes/elements/element_geometry.py delete mode 100644 routes/elements/element_move_to_container.py delete mode 100644 routes/elements/element_rename.py delete mode 100644 routes/elements/element_reorder.py delete mode 100644 routes/elements/element_save.py delete mode 100644 routes/elements/element_set_personnage_data.py delete mode 100644 routes/elements/element_tab_add.py delete mode 100644 routes/legacy_actions/__init__.py delete mode 100644 routes/legacy_actions/action_add.py delete mode 100644 routes/legacy_actions/action_delete.py delete mode 100644 routes/legacy_actions/action_run_data.py delete mode 100644 routes/objects/__init__.py delete mode 100644 routes/objects/data_delete.py delete mode 100644 routes/objects/data_edit.py delete mode 100644 routes/objects/data_form.py delete mode 100644 routes/objects/data_new.py delete mode 100644 routes/objects/object_delete.py delete mode 100644 routes/objects/object_edit.py delete mode 100644 routes/objects/object_field_add.py delete mode 100644 routes/objects/object_field_delete.py delete mode 100644 routes/objects/object_field_edit.py delete mode 100644 routes/objects/object_new.py delete mode 100644 routes/objects/object_view.py delete mode 100644 routes/objects/parse_field_rows.py create mode 100644 routes/scenes/collision_rule_objects.py create mode 100644 routes/scenes/scene_collision_rule_objects.py create mode 100644 routes/scenes/scene_object_names_list.py delete mode 100644 routes/screens/screen_set_aspect.py rename screens/{widgets => data_definitions}/data_definition_options.py (100%) delete mode 100644 screens/element_types/__init__.py delete mode 100644 screens/element_types/create_element_type.py delete mode 100644 screens/element_types/delete_element_type.py delete mode 100644 screens/element_types/element_type_usage_count.py delete mode 100644 screens/element_types/get_element_type_by_template_screen.py delete mode 100644 screens/element_types/is_overlay_only.py delete mode 100644 screens/element_types/list_element_types.py delete mode 100644 screens/element_types/load_template_tree.py delete mode 100644 screens/element_types/update_element_type.py delete mode 100644 screens/elements/__init__.py delete mode 100644 screens/elements/add_element.py delete mode 100644 screens/elements/add_tab.py delete mode 100644 screens/elements/delete_element.py delete mode 100644 screens/elements/duplicate_element.py delete mode 100644 screens/elements/element_descendant_ids.py delete mode 100644 screens/elements/element_label.py delete mode 100644 screens/elements/flow_nodes_referencing_element.py delete mode 100644 screens/elements/get_element.py delete mode 100644 screens/elements/list_elements.py delete mode 100644 screens/elements/move_element_to_container.py delete mode 100644 screens/elements/rename_element.py delete mode 100644 screens/elements/reorder_element.py delete mode 100644 screens/elements/save_element_controls.py delete mode 100644 screens/elements/set_personnage_data.py delete mode 100644 screens/elements/update_element_geometry.py delete mode 100644 screens/legacy_actions/__init__.py delete mode 100644 screens/legacy_actions/add_action.py delete mode 100644 screens/legacy_actions/delete_action.py delete mode 100644 screens/legacy_actions/get_action.py delete mode 100644 screens/legacy_actions/list_actions.py delete mode 100644 screens/rendering/apply_ctx.py delete mode 100644 screens/rendering/attr_string.py delete mode 100644 screens/rendering/auto_id.py delete mode 100644 screens/rendering/filter_repeater_rows.py delete mode 100644 screens/rendering/parent_flex_direction.py delete mode 100644 screens/rendering/render_checkbox_or_radio.py delete mode 100644 screens/rendering/render_children.py delete mode 100644 screens/rendering/render_element_html.py delete mode 100644 screens/rendering/render_fieldset.py delete mode 100644 screens/rendering/render_icone.py delete mode 100644 screens/rendering/render_jauge.py delete mode 100644 screens/rendering/render_onglets.py delete mode 100644 screens/rendering/render_overlay.py delete mode 100644 screens/rendering/render_personnage.py delete mode 100644 screens/rendering/render_repeater.py delete mode 100644 screens/rendering/render_select.py delete mode 100644 screens/rendering/render_table.py delete mode 100644 screens/rendering/resolve_bound_row.py delete mode 100644 screens/rendering/style_string.py delete mode 100644 screens/rendering/visibility_condition.py delete mode 100644 screens/rendering/visible_attrs.py create mode 100644 screens/scenes/resolve_scene_world_size.py delete mode 100644 screens/screens_repo/set_screen_aspect.py delete mode 100644 screens/widgets/__init__.py delete mode 100644 screens/widgets/border_style_options.py delete mode 100644 screens/widgets/bulma_variants.py delete mode 100644 screens/widgets/container_widgets.py delete mode 100644 screens/widgets/control_groups/__init__.py delete mode 100644 screens/widgets/control_groups/border_controls.py delete mode 100644 screens/widgets/control_groups/bulma_controls.py delete mode 100644 screens/widgets/control_groups/data_binding_controls.py delete mode 100644 screens/widgets/control_groups/hover_controls.py delete mode 100644 screens/widgets/control_groups/layout_controls.py delete mode 100644 screens/widgets/control_groups/shadow_controls.py delete mode 100644 screens/widgets/control_groups/size_override_controls.py delete mode 100644 screens/widgets/control_groups/spacing_controls.py delete mode 100644 screens/widgets/control_groups/text_style_controls_extra.py delete mode 100644 screens/widgets/control_groups/universal_controls.py delete mode 100644 screens/widgets/control_groups/visibility_condition_controls.py delete mode 100644 screens/widgets/control_groups/visibility_control.py delete mode 100644 screens/widgets/control_value.py delete mode 100644 screens/widgets/controls/__init__.py delete mode 100644 screens/widgets/controls/c_align.py delete mode 100644 screens/widgets/controls/c_checkbox.py delete mode 100644 screens/widgets/controls/c_clause_list.py delete mode 100644 screens/widgets/controls/c_color.py delete mode 100644 screens/widgets/controls/c_preset.py delete mode 100644 screens/widgets/controls/c_scale.py delete mode 100644 screens/widgets/controls/c_select.py delete mode 100644 screens/widgets/controls/c_shadow.py delete mode 100644 screens/widgets/controls/c_size.py delete mode 100644 screens/widgets/controls/c_slider.py delete mode 100644 screens/widgets/controls/c_text.py delete mode 100644 screens/widgets/controls/c_textarea.py delete mode 100644 screens/widgets/controls/c_toggle_token.py delete mode 100644 screens/widgets/controls/c_url.py delete mode 100644 screens/widgets/controls_with_values.py delete mode 100644 screens/widgets/default_style_for_widget.py delete mode 100644 screens/widgets/font_options.py delete mode 100644 screens/widgets/fontawesome_icons.py delete mode 100644 screens/widgets/layout_capable_widgets.py delete mode 100644 screens/widgets/registry.py delete mode 100644 screens/widgets/widget_meta.py delete mode 100644 static/js/screen_edit/animation-timeline.js delete mode 100644 static/js/screen_edit/flow-editor.js delete mode 100644 static/js/screen_edit/tabs-and-blocks.js delete mode 100644 templates/game_dashboard.html delete mode 100644 templates/screen_edit.html delete mode 100644 tests/test_confort.py delete mode 100644 tests/test_data_binding_clause_list.py delete mode 100644 tests/test_data_binding_refresh_marker.py delete mode 100644 tests/test_data_form_id_collision.py delete mode 100644 tests/test_delete_element_referenced_by_flow.py delete mode 100644 tests/test_element_type_bound_field_picker.py delete mode 100644 tests/test_element_type_live_instances.py delete mode 100644 tests/test_filter_value_global_variable.py delete mode 100644 tests/test_jauge.py delete mode 100644 tests/test_object_new_without_fields.py delete mode 100644 tests/test_onglets.py delete mode 100644 tests/test_onglets_widget.py delete mode 100644 tests/test_personnage_widget.py delete mode 100644 tests/test_reserved_sql_keyword_field_names.py create mode 100644 tests/test_scene_collision_rule_objects.py create mode 100644 tests/test_scene_object_add_ajax.py create mode 100644 tests/test_scene_object_delete_ajax.py create mode 100644 tests/test_scene_object_names_list.py delete mode 100644 tests/test_screen_layout_fixes.py delete mode 100644 tests/test_screens_and_elements.py delete mode 100644 tests/test_shadow_controls.py delete mode 100644 tests/test_variable_object_array.py delete mode 100644 tests/test_visibility_condition.py delete mode 100644 tests/test_visibility_toggle_markers.py diff --git a/.gitignore b/.gitignore index 4b2ac312..9d4d5502 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ assets/background/ Thumbs.db .vscode/ .idea/ + +# Documents internes/business (cadrage produit...) et état local de +# session Claude Code — jamais du code, pas destiné à l'historique partagé. +.claude/ diff --git a/app.py b/app.py index d827796d..c7322838 100644 --- a/app.py +++ b/app.py @@ -33,4 +33,12 @@ def _open_browser(): if __name__ == "__main__": threading.Timer(1.0, _open_browser).start() - app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False) + # threaded=True : le serveur de dev Werkzeug traite UNE requête à la + # fois par défaut — un jeu 2D charge des dizaines d'images (sprites de + # personnages, fonds...) en parallèle à chaque chargement de page, et + # sans ce réglage elles se mettaient en file d'attente une par une + # (bug remonté : un Ctrl+F5, qui recharge TOUT sans cache, laissait + # l'éditeur de scène "vide" le temps que les images finissent par + # arriver l'une après l'autre — un rechargement normal, servi surtout + # depuis le cache navigateur, le cachait). + app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True) diff --git a/core/jinja_filters.py b/core/jinja_filters.py index 184164a5..893eb49a 100644 --- a/core/jinja_filters.py +++ b/core/jinja_filters.py @@ -1,12 +1,6 @@ -import screens - from core.flask_app import app from filters.colname_filter import _colname -from filters.element_style_filter import _element_style, _element_transform_style from filters.truncate_sentences_filter import _truncate_sentences app.jinja_env.filters["colname"] = _colname -app.jinja_env.filters["elstyle"] = _element_style -app.jinja_env.filters["eltransform"] = _element_transform_style -app.jinja_env.filters["elabel"] = screens.element_label app.jinja_env.filters["truncatesentences"] = _truncate_sentences diff --git a/db/games/game_type_catalog.py b/db/games/game_type_catalog.py index b4308b73..89f4b7ba 100644 --- a/db/games/game_type_catalog.py +++ b/db/games/game_type_catalog.py @@ -15,48 +15,45 @@ rendu jouable, seulement par le routage (routes/games/game_dashboard.py, routes/screens/screens_new.py).""" import db -DEFAULT_ONBOARDING_TYPE = "custom" +DEFAULT_ONBOARDING_TYPE = "rpg" ONBOARDING_TYPES = { - "quiz": { - "label": "Quiz", - "tagline": "Questions, réponses, score.", - "icon": "🎯", - "description": "Gérer des questions/réponses, avec score.", - "screen_kind": "document", - "restricted": True, - }, - "embranchement": { - "label": "Jeu à embranchement / escape game", - "tagline": "Chaque choix a une conséquence.", - "icon": "🗝️", - "description": ( - "Former à travers le jeu sur des concepts métier avec des " - "conséquences observables ; réaliser des escape games sous " - "forme d'énigmes." - ), - "screen_kind": "document", - "restricted": True, - }, "rpg": { - "label": "Jeu RPG", + "label": "Créer un jeu 2D ludique", "tagline": "Ton monde, ton héros, ton scénario.", "icon": "🗺️", - "description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.", + # Argumentaire pour un public RH non technique (voir .claude/ + # Forge_Engine_Cadrage.pdf) : chaque point correspond à une + # fonctionnalité RÉELLEMENT construite (assistant "+ Action" de + # l'éditeur de collision pour les interactions, "❓ Boîte à quiz"/ + # "🏆 Score" posables comme n'importe quel objet de scène, dialogue + # de quête par état "Nouvelle/En cours/Terminée", export SCORM 1.2 + # natif), jamais une promesse. Du HTML de confiance (jamais de + # saisie utilisateur ici) — rendu via `|safe` dans + # templates/onboarding/onboarding_new.html, stylé par + # .onboardingPitch* dans static/style.css : liste à coche + badge + # LMS, pensé pour convaincre en un coup d'œil plutôt qu'un pavé de + # texte brut. + "description": ( + 'Un jeu 2D ludique,' + " sans une ligne de code" + '
    ' + '
  • ✓' + "Personnages vivants — posez-les, donnez-leur" + " un rôle, ils bougent et réagissent seuls
  • " + '
  • ✓' + "Quiz animés — intégrés à la scène," + " correction immédiate
  • " + '
  • ✓' + "Score & dialogues — évoluent avec" + " chaque quête
  • " + "
" + '🎓 Compatible LMS —' + " export SCORM en 1 clic" + ), "screen_kind": "jeu_2d", "restricted": True, }, - "custom": { - "label": "Créer mon jeu de A à Z", - "tagline": "Toute la puissance du moteur, sans limite.", - "icon": "⚡", - "description": ( - "Pour les utilisateurs avec des notions de dev/jeu vidéo : " - "accès à toute la puissance du moteur." - ), - "screen_kind": "document", - "restricted": False, - }, } diff --git a/db/games/get_game_type.py b/db/games/get_game_type.py index 58162d1f..b214707c 100644 --- a/db/games/get_game_type.py +++ b/db/games/get_game_type.py @@ -1,6 +1,6 @@ from ..connection import connect -DEFAULT_GAME_TYPE = "document" +DEFAULT_GAME_TYPE = "jeu_2d" def get_game_type(slug): diff --git a/filters/element_style_filter.py b/filters/element_style_filter.py deleted file mode 100644 index c824ba82..00000000 --- a/filters/element_style_filter.py +++ /dev/null @@ -1,76 +0,0 @@ -def _element_style(el): - """Construit le style inline de POSITIONNEMENT (position/taille en % + - empilement) du cadre qui porte un élément posé sur un écran. L'apparence - de l'élément lui-même (couleurs, police, etc.) vit dans le HTML généré - par screens.render_element_html — ce filtre ne gère que le placement, - partagé par l'éditeur et le mode jouable. - - Ce cadre (.canvasElement / .playElement) est aussi la cible des - animations Animate.css (data-anim-target, voir screen_edit.html et - play.html) : il ne porte donc PAS le "transform" (échelle/rotation) de - l'élément, pour ne jamais entrer en collision avec le "transform" que - Animate.css pilote lui-même pendant qu'une animation joue (Animate.css - pose "animation-fill-mode: both", qui remplace entièrement la valeur de - la propriété — un "transform" inline posé ici serait écrasé pendant, et - pour une animation en boucle infinie, en permanence). Voir - _element_transform_style, qui porte ce "transform" sur un cadre séparé - (.canvasElementInner / .playElementInner), à la taille exacte de celui-ci - (donc visuellement identique). - - Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà - x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999 - sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même - "z-index:{z_index}" (le z-index de sa place dans le canevas, souvent - petit), un élément positionné avec un z-index EXPLICITE (même sur ce - cadre, pas sur son contenu) devient un NOUVEAU contexte d'empilement - CSS — le z-index:9999 posé plus profond ne se comparait alors plus - qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un - frère ajouté après lui sur le canevas, qui se retrouvait affiché - PAR-DESSUS le dialogue censé tout couvrir. - - Première correction tentée (retirer aussi position/left/top/width/ - height ici) : régression dans l'ÉDITEUR — ce même cadre (.canvasElement) - sert aussi de prise pour glisser-déposer/redimensionner CE widget dans - le canevas (voir screen_edit.html), dont le calcul se base sur ses - dimensions réelles ; sans position:absolute + left/top/width/height, ce - cadre s'effondre à 0×0 (son contenu réel est en position:fixed, donc - hors flux), le calcul division par zéro produit NaN, et - JSON.stringify(NaN) donne "null" — element_geometry.py plantait alors - sur float(None). left/top/width/height restent donc posés comme pour - n'importe quel widget (l'éditeur continue de fonctionner normalement) : - SEUL le z-index est omis pour ce widget précis. position:absolute SANS - z-index explicite (donc z-index:auto) ne crée PAS de nouveau contexte - d'empilement — le z-index:9999 posé plus profond se compare alors - directement aux autres éléments de l'écran, et gagne toujours. - - Même omission pour un exemplaire (element_type_id) d'un élément de jeu - dont le modèle N'EST QU'une superposition (marqué "_overlay_only_instance" - par list_elements.py) : son propre widget est "conteneur" par défaut - (add_element.py), pas "superposition", mais son cadre entoure quand - même une superposition rendue à l'intérieur (voir - render_element_html.py/_render_element_type_children) — exactement le - même risque de contexte d'empilement piégeant.""" - exempt_from_z_index = ( - el.get("widget") == "superposition" or el.get("_overlay_only_instance") - ) - parts = [ - "position:absolute", - f"left:{el['x']}%", f"top:{el['y']}%", - f"width:{el['width']}%", f"height:{el['height']}%", - ] - if not exempt_from_z_index: - parts.append(f"z-index:{el['z_index']}") - return "; ".join(parts) + ";" - - -def _element_transform_style(el): - """Style inline de l'échelle/rotation d'un élément posé directement sur - un écran — voir _element_style pour pourquoi ce "transform" est séparé - du cadre de positionnement/animation. Ce cadre intermédiaire - (.canvasElementInner / .playElementInner) fait exactement la même taille - que le cadre extérieur (width/height:100%, voir static/style.css et - play.html) : y poser le "transform" produit donc le même rendu visuel - que s'il était sur le cadre extérieur, sans le risque de collision avec - Animate.css.""" - transform = (el.get("style") or {}).get("transform") - return f"transform:{transform};" if transform else "" diff --git a/routes/__init__.py b/routes/__init__.py index 0a52327b..b25fb67d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests +from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests diff --git a/routes/element_types/__init__.py b/routes/element_types/__init__.py deleted file mode 100644 index dadc6b56..00000000 --- a/routes/element_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import element_types, element_type_edit, element_type_delete diff --git a/routes/element_types/element_type_delete.py b/routes/element_types/element_type_delete.py deleted file mode 100644 index 1172c0bf..00000000 --- a/routes/element_types/element_type_delete.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import redirect, url_for - -import screens - -from core.flask_app import app - - -@app.route("/game//element-types//delete", methods=["POST"]) -def element_type_delete(slug, element_type_id): - if screens.element_type_usage_count(slug, element_type_id) == 0: - screens.delete_element_type(slug, element_type_id) - return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/element_types/element_type_edit.py b/routes/element_types/element_type_edit.py deleted file mode 100644 index 75660849..00000000 --- a/routes/element_types/element_type_edit.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import request, redirect, url_for - -import screens - -from core.flask_app import app - - -@app.route("/game//element-types//edit", methods=["POST"]) -def element_type_edit(slug, element_type_id): - """Renomme un élément de jeu et/ou change l'objet auquel il est lié — - son contenu (l'arbre d'éléments) se modifie séparément, via le lien - "✏️ Modifier le contenu" qui ouvre l'écran-modèle dans l'éditeur normal.""" - name = request.form.get("name", "").strip() or None - bound_definition_id = request.form.get("bound_definition_id") or None - screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id) - return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/element_types/element_types.py b/routes/element_types/element_types.py deleted file mode 100644 index f4f9fe6d..00000000 --- a/routes/element_types/element_types.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import request, redirect, url_for - -import screens - -from core.flask_app import app - - -@app.route("/game//element-types", methods=["GET", "POST"]) -def element_types(slug): - """La création/gestion d'un élément de jeu se fait désormais depuis - l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html) - plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la - création (barre de création repliable de cet onglet). Un GET direct - redirige vers le tableau de bord.""" - if request.method == "POST": - name = request.form.get("name", "").strip() - icon = request.form.get("icon", "").strip() or "🧩" - bound_definition_id = request.form.get("bound_definition_id") or None - if name: - screens.create_element_type(slug, name, icon, bound_definition_id) - return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) - return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py deleted file mode 100644 index bbad48e1..00000000 --- a/routes/elements/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from . import ( - element_add, - element_add_child, - element_geometry, - element_reorder, - element_move_to_container, - element_save, - element_delete, - element_delete_impact, - element_duplicate, - element_rename, - element_tab_add, - element_set_personnage_data, -) diff --git a/routes/elements/element_add.py b/routes/elements/element_add.py deleted file mode 100644 index 45cb24d0..00000000 --- a/routes/elements/element_add.py +++ /dev/null @@ -1,37 +0,0 @@ -from flask import request, redirect, url_for - -import screens - -from core.flask_app import app -from core.sprite_gate import forbid_admin_only_character - - -@app.route("/game//screens//elements/add", methods=["POST"]) -def element_add(slug, screen_id): - widget = request.form.get("widget", "conteneur") - from_catalogue = widget == "__catalogue__" - element_type_id = request.form.get("element_type_id") or None if from_catalogue else None - if from_catalogue and not element_type_id: - return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) - if from_catalogue: - types = {t["id"]: t for t in screens.list_element_types(slug)} - et = types.get(int(element_type_id)) - widget = et["default_widget"] if et else "conteneur" - new_id = screens.add_element(slug, screen_id, widget, element_type_id) - icon_slug = request.form.get("icon_class") - if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS: - # Posé depuis la galerie d'icônes (voir le panneau de propriétés, - # section "Icônes") : l'icône choisie remplace celle posée par - # défaut à la création plutôt que de repartir d'une icône neutre que - # l'utilisateur devrait ensuite changer manuellement. - screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug}) - if widget == "personnage": - # Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement() - # dans tree-panels.js) : remplace le personnage Forge par défaut - # (male-adventurer, voir default_style_for_widget.py) par celui - # réellement cliqué. - forge_character = request.form.get("forge_character") - if forge_character and forge_character in screens.SPRITE_LIBRARY: - forbid_admin_only_character(forge_character) - screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character}) - return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id)) diff --git a/routes/elements/element_add_child.py b/routes/elements/element_add_child.py deleted file mode 100644 index ad819488..00000000 --- a/routes/elements/element_add_child.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//children/add", methods=["POST"]) -def element_add_child(slug, parent_id): - """Ajoute un élément PHYSIQUEMENT à l'intérieur d'un conteneur, d'un - répéteur de données ou d'un groupe de champs déjà posé sur l'écran.""" - parent = screens.get_element(slug, parent_id) - if not parent: - abort(404) - widget = request.form.get("widget", "conteneur") - from_catalogue = widget == "__catalogue__" - element_type_id = request.form.get("element_type_id") or None if from_catalogue else None - if from_catalogue and element_type_id: - types = {t["id"]: t for t in screens.list_element_types(slug)} - et = types.get(int(element_type_id)) - widget = et["default_widget"] if et else "conteneur" - elif from_catalogue: - return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=parent_id)) - new_id = screens.add_element(slug, parent["screen_id"], widget, element_type_id, parent_id=parent_id) - return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=new_id)) diff --git a/routes/elements/element_delete.py b/routes/elements/element_delete.py deleted file mode 100644 index 2cdcda48..00000000 --- a/routes/elements/element_delete.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//delete", methods=["POST"]) -def element_delete(slug, element_id): - element = screens.get_element(slug, element_id) - if not element: - abort(404) - screen_id = element["screen_id"] - parent_id = element.get("parent_id") - screens.delete_element(slug, element_id) - # Resélectionne le conteneur parent (s'il y en a un) après suppression, - # plutôt que de laisser le panneau de droite vide : sinon, supprimer un - # élément fait brusquement "sauter" l'éditeur vers un état sans rien de - # sélectionné. - if parent_id: - return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=parent_id)) - return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) diff --git a/routes/elements/element_delete_impact.py b/routes/elements/element_delete_impact.py deleted file mode 100644 index 025adefe..00000000 --- a/routes/elements/element_delete_impact.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//delete-impact") -def element_delete_impact(slug, element_id): - """Appelé par la modale de confirmation de suppression (screen_edit.html) - AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la - Logique de la scène (déclencheur/action) seraient aussi supprimés en - même temps que l'élément — voir delete_element.py, qui les supprime - silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY - constraint failed", voir son commentaire).""" - return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)}) diff --git a/routes/elements/element_duplicate.py b/routes/elements/element_duplicate.py deleted file mode 100644 index bca9841b..00000000 --- a/routes/elements/element_duplicate.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//duplicate", methods=["POST"]) -def element_duplicate(slug, element_id): - element = screens.get_element(slug, element_id) - if not element: - abort(404) - new_id = screens.duplicate_element(slug, element_id) - return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id)) diff --git a/routes/elements/element_geometry.py b/routes/elements/element_geometry.py deleted file mode 100644 index f054fd37..00000000 --- a/routes/elements/element_geometry.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import request, jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//geometry", methods=["POST"]) -def element_geometry(slug, element_id): - """Appelé en AJAX pendant/à la fin d'un glisser-déposer, d'un - redimensionnement, ou d'une modification des champs Position & taille — - ne touche qu'à la position/taille, aucun rechargement de page nécessaire - pour que ça reste fluide.""" - data = request.get_json(force=True) - screens.update_element_geometry( - slug, element_id, - x=float(data["x"]), y=float(data["y"]), - width=float(data["width"]), height=float(data["height"]), - ) - return jsonify({"ok": True}) diff --git a/routes/elements/element_move_to_container.py b/routes/elements/element_move_to_container.py deleted file mode 100644 index 9b265527..00000000 --- a/routes/elements/element_move_to_container.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import request, jsonify, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//move-to-container", methods=["POST"]) -def element_move_to_container(slug, element_id): - """Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le - déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en - dessous — le fait passer enfant de ce conteneur, en dernière position - (voir move_element_to_container, qui refuse aussi silencieusement les - cas invalides : cible non-conteneur, ou cycle).""" - element = screens.get_element(slug, element_id) - if not element: - abort(404) - data = request.get_json(force=True) - new_parent_id = int(data["parent_id"]) - screens.move_element_to_container(slug, element_id, new_parent_id) - return jsonify({"ok": True}) diff --git a/routes/elements/element_rename.py b/routes/elements/element_rename.py deleted file mode 100644 index c69fa754..00000000 --- a/routes/elements/element_rename.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//rename", methods=["POST"]) -def element_rename(slug, element_id): - element = screens.get_element(slug, element_id) - if not element: - abort(404) - screens.rename_element(slug, element_id, request.form.get("name", "")) - return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id)) diff --git a/routes/elements/element_reorder.py b/routes/elements/element_reorder.py deleted file mode 100644 index a3125505..00000000 --- a/routes/elements/element_reorder.py +++ /dev/null @@ -1,22 +0,0 @@ -from flask import request, jsonify, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//reorder", methods=["POST"]) -def element_reorder(slug, element_id): - """Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un - autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre - des éléments d'un même conteneur — voir reorder_element.""" - element = screens.get_element(slug, element_id) - if not element: - abort(404) - data = request.get_json(force=True) - target_id = int(data["target_id"]) - position = data.get("position") - if position not in ("before", "after"): - abort(400) - screens.reorder_element(slug, element["screen_id"], element_id, target_id, position) - return jsonify({"ok": True}) diff --git a/routes/elements/element_save.py b/routes/elements/element_save.py deleted file mode 100644 index 95e42d99..00000000 --- a/routes/elements/element_save.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//save", methods=["POST"]) -def element_save(slug, element_id): - """Un seul formulaire pour tout le panneau de propriétés : un champ par - réglage du widget (texte, case à cocher, curseur, couleur...) — c'est - screens.save_element_controls qui sait vers quel attribut ou quelle - propriété CSS chaque réglage doit être traduit.""" - element = screens.get_element(slug, element_id) - if not element: - abort(404) - screens.save_element_controls(slug, element_id, request.form) - return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id)) diff --git a/routes/elements/element_set_personnage_data.py b/routes/elements/element_set_personnage_data.py deleted file mode 100644 index 881907d3..00000000 --- a/routes/elements/element_set_personnage_data.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask import request, jsonify - -import screens - -from core.flask_app import app -from core.sprite_gate import forbid_admin_only_character - - -@app.route("/game//elements//personnage", methods=["POST"]) -def element_set_personnage_data(slug, element_id): - """Change le personnage Forge d'un élément déjà posé (galerie du - panneau de propriétés), ou met à jour ses animations personnalisées - (import de frames) — voir personnage-panel.js. Renvoie les animations - résolues pour que le client relance immédiatement l'aperçu animé sans - recharger la page (voir personnage-preview.js).""" - body = request.get_json(silent=True) or {} - el = screens.get_element(slug, element_id) - if not el: - return jsonify({"error": "élément introuvable"}), 404 - if body.get("source") == "forge" and body.get("forge_character"): - forbid_admin_only_character(body["forge_character"]) - screens.set_personnage_data(slug, element_id, body) - el = screens.get_element(slug, element_id) - return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)}) diff --git a/routes/elements/element_tab_add.py b/routes/elements/element_tab_add.py deleted file mode 100644 index 4dffd447..00000000 --- a/routes/elements/element_tab_add.py +++ /dev/null @@ -1,14 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//tabs/add", methods=["POST"]) -def element_tab_add(slug, element_id): - onglets = screens.get_element(slug, element_id) - if not onglets: - abort(404) - screens.add_tab(slug, element_id, onglets["screen_id"]) - return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id)) diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index a1c10fed..815c7c34 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -8,25 +8,12 @@ from core.flask_app import app @app.route("/game/") def game_dashboard(slug): - """Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets, - éléments de jeu réutilisables, variables), en un coup d'œil, avec un - accès direct à chaque élément — la barre de navigation du jeu (voir - base.html) permet déjà de sauter directement vers chaque éditeur, cette - page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes - liens. - - Onboarding guidé (voir db/games/game_type_catalog.py) : un compte - "restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE — - juste ses écrans, en cartes avec un aperçu de leur contenu, plus un - bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le - projet : "jeu_2d" pour un RPG, "document" pour les autres — voir - routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/ - variables restent réservés à "Créer mon jeu de A à Z", la seule - formule pensée pour la puissance complète du moteur.""" + """Vue d'ensemble du jeu : juste ses écrans, en cartes avec un aperçu + de leur contenu, plus un bouton "+ Ajouter un écran" qui ne demande pas + de type (un seul moteur d'écran : jeu_2d — voir + routes/screens/screens_new.py).""" game = db.game_meta(slug) - if db.is_restricted(slug): - return _render_simple_dashboard(slug, game) - return _render_full_dashboard(slug, game) + return _render_simple_dashboard(slug, game) def _render_simple_dashboard(slug, game): @@ -37,61 +24,17 @@ def _render_simple_dashboard(slug, game): objects = screens.list_scene_objects(slug, s["id"]) for o in objects: o["rendered_html"] = screens.render_scene_object(o) - previews[s["id"]] = {"kind": "jeu_2d", "objects": objects} - else: + # Taille du MONDE (voir screens.resolve_scene_world_size, + # même calcul que l'éditeur, routes/scenes/scene_edit_view.py) + # plutôt que scene_width/scene_height (la caméra nominale) : + # un "fond" plus grand que la caméra dépassait alors le cadre + # de la miniature (game_dashboard_simple.html), qui n'en + # montrait que le coin haut-gauche au lieu de la scène entière. + world_width, world_height = screens.resolve_scene_world_size(s, objects) previews[s["id"]] = { - "kind": "document", - "elements": screens.list_elements(slug, s["id"]), + "kind": "jeu_2d", "objects": objects, + "world_width": world_width, "world_height": world_height, } return render_template( "game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews, ) - - -def _render_full_dashboard(slug, game): - definitions = db.list_definitions(slug) - # Détail complet (avec ses champs) de chaque objet, pour le panneau - # d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon - # ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et - # simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à - # l'ouverture (voir openObjectEditPanel()). - definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions} - - # Pour l'onglet "Données" de ce même panneau (remplace data_list.html/ - # object_view.py, retirés) : les lignes déjà enregistrées de chaque - # objet, les libellés lisibles de ses champs relation (pour l'affichage - # du tableau) et les options possibles de ces mêmes champs relation - # (pour le formulaire "Ajouter une entrée"). - rows_by_definition = {} - relation_labels_by_definition = {} - relation_options_by_definition = {} - referenced_by_definition = {} - for d in definitions: - full = definitions_full[d["id"]] - rows_by_definition[d["id"]] = db.list_rows(slug, full) - relation_labels = {} - relation_options = {} - for f in full["fields"]: - if f["type"] == "relation": - options = db.relation_options(slug, f["relation_definition_id"]) - relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options} - relation_options[f["id"]] = options - relation_labels_by_definition[d["id"]] = relation_labels - relation_options_by_definition[d["id"]] = relation_options - referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"]) - - screen_list = screens.list_screens(slug) - element_types = screens.list_element_types(slug) - element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types} - variables = db.list_global_variables(slug) - return render_template( - "game_dashboard.html", game=game, definitions=definitions, - definitions_full=definitions_full, - rows_by_definition=rows_by_definition, - relation_labels_by_definition=relation_labels_by_definition, - relation_options_by_definition=relation_options_by_definition, - referenced_by_definition=referenced_by_definition, - screen_list=screen_list, element_types=element_types, - element_type_usage=element_type_usage, variables=variables, - variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES, - ) diff --git a/routes/legacy_actions/__init__.py b/routes/legacy_actions/__init__.py deleted file mode 100644 index 99f39ce3..00000000 --- a/routes/legacy_actions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import action_add, action_delete, action_run_data diff --git a/routes/legacy_actions/action_add.py b/routes/legacy_actions/action_add.py deleted file mode 100644 index 565a2c14..00000000 --- a/routes/legacy_actions/action_add.py +++ /dev/null @@ -1,35 +0,0 @@ -from flask import request, redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//elements//actions/add", methods=["POST"]) -def action_add(slug, element_id): - element = screens.get_element(slug, element_id) - if not element: - abort(404) - trigger_event = screens._trigger_for(element["widget"], element["attributes"]) - action_type = request.form.get("action_type", "rien") - - def _int_or_none(key): - raw = request.form.get(key) - try: - return int(raw) if raw not in (None, "") else None - except (TypeError, ValueError): - return None - - screens.add_action( - slug, element_id, trigger_event, action_type, - target_screen_id=_int_or_none("target_screen_id"), - target_element_id=_int_or_none("target_element_id"), - element_property=request.form.get("element_property") or None, - element_value=request.form.get("element_value") or None, - target_definition_id=_int_or_none("target_definition_id"), - target_row_id=_int_or_none("target_row_id"), - target_field=request.form.get("target_field") or None, - data_operation=request.form.get("data_operation") or None, - data_value=request.form.get("data_value") or None, - ) - return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id)) diff --git a/routes/legacy_actions/action_delete.py b/routes/legacy_actions/action_delete.py deleted file mode 100644 index 225172c5..00000000 --- a/routes/legacy_actions/action_delete.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import redirect, url_for, abort - -import screens - -from core.flask_app import app - - -@app.route("/game//actions//delete", methods=["POST"]) -def action_delete(slug, action_id): - action = screens.get_action(slug, action_id) - if not action: - abort(404) - element = screens.get_element(slug, action["element_id"]) - screens.delete_action(slug, action_id) - return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element["id"])) diff --git a/routes/legacy_actions/action_run_data.py b/routes/legacy_actions/action_run_data.py deleted file mode 100644 index 2971f5e7..00000000 --- a/routes/legacy_actions/action_run_data.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//actions//run-data", methods=["POST"]) -def action_run_data(slug, action_id): - """Exécuté côté serveur au moment du clic, en mode jouable, pour une - action "Modifier une donnée d'un objet" — les actions "Modifier un - élément" restent, elles, purement côté client (juste du style CSS).""" - action = screens.get_action(slug, action_id) - if not action or action.get("action_type") != "modifier_donnee": - return jsonify({"ok": False}), 400 - ok = screens.apply_data_action(slug, action) - return jsonify({"ok": ok}) diff --git a/routes/objects/__init__.py b/routes/objects/__init__.py deleted file mode 100644 index 3d5052c3..00000000 --- a/routes/objects/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from . import ( - parse_field_rows, - object_new, - object_view, - object_edit, - object_field_add, - object_field_edit, - object_field_delete, - object_delete, - data_form, - data_new, - data_edit, - data_delete, -) diff --git a/routes/objects/data_delete.py b/routes/objects/data_delete.py deleted file mode 100644 index e1cda644..00000000 --- a/routes/objects/data_delete.py +++ /dev/null @@ -1,21 +0,0 @@ -from flask import redirect, url_for, abort - -import db - -from core.flask_app import app - - -@app.route("/game//objects//data//delete", methods=["POST"]) -def data_delete(slug, definition_id, row_id): - definition = db.get_definition(slug, definition_id) - if not definition: - abort(404) - referencing = db.rows_referencing(slug, definition_id, row_id) - if referencing: - # Suppression bloquée : d'autres lignes pointent vers celle-ci via - # une relation. On revient au panneau de cet objet (onglet - # Données), qui explique pourquoi via un paramètre de requête - # simple plutôt qu'une session/flash message. - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id)) - db.delete_row(slug, definition, row_id) - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data")) diff --git a/routes/objects/data_edit.py b/routes/objects/data_edit.py deleted file mode 100644 index 462887cd..00000000 --- a/routes/objects/data_edit.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import abort - -import db - -from core.flask_app import app -from .data_form import _data_form - - -@app.route("/game//objects//data//edit", methods=["GET", "POST"]) -def data_edit(slug, definition_id, row_id): - definition = db.get_definition(slug, definition_id) - if not definition: - abort(404) - row = db.get_row(slug, definition, row_id) - if not row: - abort(404) - return _data_form(slug, definition_id, row=row) diff --git a/routes/objects/data_form.py b/routes/objects/data_form.py deleted file mode 100644 index 49e68601..00000000 --- a/routes/objects/data_form.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask import request, redirect, url_for, abort - -import db - - -def _data_form(slug, definition_id, row): - """Ajouter/modifier une entrée se fait désormais depuis l'onglet - "Données" du panneau déplaçable de son objet (voir game_dashboard.html, - ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée - (data_form.html, retirée) — cette fonction ne fait donc plus que - traiter la soumission de ce formulaire. Un GET direct redirige vers le - tableau de bord, en rouvrant le panneau de cet objet sur son onglet - Données.""" - definition = db.get_definition(slug, definition_id) - if not definition: - abort(404) - - if request.method == "POST": - if row is None: - db.insert_row(slug, definition, request.form) - else: - db.update_row(slug, definition, row["id"], request.form) - - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data")) diff --git a/routes/objects/data_new.py b/routes/objects/data_new.py deleted file mode 100644 index ccd2f97a..00000000 --- a/routes/objects/data_new.py +++ /dev/null @@ -1,7 +0,0 @@ -from core.flask_app import app -from .data_form import _data_form - - -@app.route("/game//objects//data/new", methods=["GET", "POST"]) -def data_new(slug, definition_id): - return _data_form(slug, definition_id, row=None) diff --git a/routes/objects/object_delete.py b/routes/objects/object_delete.py deleted file mode 100644 index 53623134..00000000 --- a/routes/objects/object_delete.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask import redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//delete", methods=["POST"]) -def object_delete(slug, definition_id): - referenced_by = db.definitions_referencing(slug, definition_id) - if referenced_by: - # Suppression bloquée : d'autres objets ont une relation vers - # celui-ci. On revient à son panneau, qui affiche déjà cet - # avertissement (referenced_by_definition, voir game_dashboard.py) - # en permanence, pas seulement après une tentative — plutôt que de - # casser ces relations silencieusement. - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id)) - db.delete_definition(slug, definition_id) - return redirect(url_for("game_dashboard", slug=slug, tab="objects")) diff --git a/routes/objects/object_edit.py b/routes/objects/object_edit.py deleted file mode 100644 index 4ee56339..00000000 --- a/routes/objects/object_edit.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask import request, redirect, url_for, abort - -import db - -from core.flask_app import app - - -@app.route("/game//objects//edit", methods=["GET", "POST"]) -def object_edit(slug, definition_id): - """Renommer un objet + gérer ses champs se fait désormais depuis un - panneau déplaçable/redimensionnable sur le tableau de bord (voir - game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt - qu'une page dédiée — cette route ne fait donc plus que traiter le - renommage. Un GET direct redirige vers le tableau de bord, en rouvrant - le panneau de cet objet (?edit=).""" - if request.method == "POST": - definition = db.get_definition(slug, definition_id) - if not definition: - abort(404) - new_name = request.form.get("object_name", "").strip() - if new_name and new_name != definition["name"]: - db.rename_definition(slug, definition_id, new_name) - - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id)) diff --git a/routes/objects/object_field_add.py b/routes/objects/object_field_add.py deleted file mode 100644 index e383ab53..00000000 --- a/routes/objects/object_field_add.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import request, redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//fields/add", methods=["POST"]) -def object_field_add(slug, definition_id): - name = request.form.get("field_name", "").strip() - field_type = request.form.get("field_type", "texte") - relation_definition_id = request.form.get("field_relation") - # Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet - # cible valide ferait planter add_field_to_definition (int("") lève), - # ce qui annulait l'ALTER TABLE avant son commit() -- le champ - # n'était alors ajouté du tout, sans le moindre message d'erreur. - if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()): - db.add_field_to_definition(slug, definition_id, { - "name": name, - "type": field_type, - "relation_definition_id": relation_definition_id, - "required": request.form.get("field_required") == "1", - "min_value": request.form.get("field_min") or None, - "max_value": request.form.get("field_max") or None, - }) - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id)) diff --git a/routes/objects/object_field_delete.py b/routes/objects/object_field_delete.py deleted file mode 100644 index c6247f77..00000000 --- a/routes/objects/object_field_delete.py +++ /dev/null @@ -1,11 +0,0 @@ -from flask import redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//fields//delete", methods=["POST"]) -def object_field_delete(slug, definition_id, field_id): - db.delete_field(slug, definition_id, field_id) - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id)) diff --git a/routes/objects/object_field_edit.py b/routes/objects/object_field_edit.py deleted file mode 100644 index 8066a2e2..00000000 --- a/routes/objects/object_field_edit.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import request, redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//objects//fields//edit", methods=["POST"]) -def object_field_edit(slug, definition_id, field_id): - new_name = request.form.get("field_name", "").strip() - if new_name: - db.update_field( - slug, definition_id, field_id, - new_name=new_name, - required=request.form.get("field_required") == "1", - relation_definition_id=request.form.get("field_relation"), - min_value=request.form.get("field_min") or None, - max_value=request.form.get("field_max") or None, - ) - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id)) diff --git a/routes/objects/object_new.py b/routes/objects/object_new.py deleted file mode 100644 index 2a76a90c..00000000 --- a/routes/objects/object_new.py +++ /dev/null @@ -1,35 +0,0 @@ -from flask import request, redirect, url_for - -import db - -from core.flask_app import app -from .parse_field_rows import _parse_field_rows - - -@app.route("/game//objects/new", methods=["GET", "POST"]) -def object_new(slug): - """La création d'un objet se fait désormais depuis un panneau déplaçable - sur le tableau de bord du jeu (voir game_dashboard.html, formulaire - #objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus - que traiter la soumission de CE formulaire. Un GET direct (ex. lien - externe obsolète) redirige simplement vers le tableau de bord.""" - if request.method == "POST": - name = request.form.get("object_name", "").strip() - # Aucun champ requis à la création : create_definition(fields=[]) - # crée simplement une table sans colonne "métier" (juste id/ - # created_at), et le panneau "Modifier un objet" (onglet Champs, - # bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins - # un champ ici faisait échouer silencieusement la création (aucun - # message d'erreur, juste un retour au tableau de bord) dès qu'on - # tapait le nom sans avoir encore cliqué "+ Ajouter un champ". - fields = _parse_field_rows(request.form) - # État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE - # explicitement rend l'objet partagé par tous les joueurs — ex. un - # catalogue/une liste de questions — voir db/definitions/ - # create_definition.py). - per_player = request.form.get("per_player", "1") == "1" - if name: - definition_id = db.create_definition(slug, name, fields, per_player=per_player) - return redirect(url_for("object_view", slug=slug, definition_id=definition_id)) - - return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/objects/object_view.py b/routes/objects/object_view.py deleted file mode 100644 index d05b504e..00000000 --- a/routes/objects/object_view.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import redirect, url_for - -from core.flask_app import app - - -@app.route("/game//objects/") -def object_view(slug, definition_id): - """Ancienne page de visualisation des données d'un objet (data_list.html) — - retirée : les données vivent désormais dans l'onglet "Données" du - panneau déplaçable de cet objet (voir game_dashboard.html). Cette - route reste néanmoins en place, comme pur redirecteur, pour deux - raisons : un lien externe/ancien vers cette URL continue de mener - quelque part de sensé, et surtout beaucoup de tests (et certains - formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER - segment du chemin renvoyé par le header Location d'une redirection - (ex. .../objects/) — object_new redirige donc ICI (chemin qui se - termine par l'id), qui redirige ensuite vers le tableau de bord.""" - return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id)) diff --git a/routes/objects/parse_field_rows.py b/routes/objects/parse_field_rows.py deleted file mode 100644 index 148aab27..00000000 --- a/routes/objects/parse_field_rows.py +++ /dev/null @@ -1,36 +0,0 @@ -def _parse_field_rows(form): - field_names = form.getlist("field_name[]") - field_types = form.getlist("field_type[]") - field_relations = form.getlist("field_relation[]") - field_required = form.getlist("field_required[]") - field_min = form.getlist("field_min[]") - field_max = form.getlist("field_max[]") - fields = [] - for i, fname in enumerate(field_names): - if not fname.strip(): - continue - ftype = field_types[i] - relation_definition_id = field_relations[i] if i < len(field_relations) else None - if ftype == "relation" and not (relation_definition_id or "").strip().isdigit(): - # Type "Relation vers un autre objet" choisi sans (ou avant - # d'avoir pu) sélectionner d'objet cible réel — ex. le tout - # premier objet créé dans un jeu, quand ce sélecteur est encore - # vide faute d'un autre objet à pointer. create_definition() - # fait un int(relation_definition_id) sans filet : le laisser - # passer plantait TOUTE la requête (500), et comme la ligne - # _definitions n'était alors jamais validée (pas de commit() - # atteint), l'objet n'était pas créé du tout — pas de champ - # "Relation" non plus, mais un panneau qui semble "ne rien - # faire" plutôt qu'un message d'erreur clair. On ignore donc ce - # champ précis (comme une ligne sans nom, ci-dessus) plutôt que - # de faire échouer la création de TOUT l'objet à cause de lui. - continue - fields.append({ - "name": fname.strip(), - "type": ftype, - "relation_definition_id": relation_definition_id, - "required": field_required[i] == "1" if i < len(field_required) else False, - "min_value": (field_min[i].strip() or None) if i < len(field_min) else None, - "max_value": (field_max[i].strip() or None) if i < len(field_max) else None, - }) - return fields diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index f721b74a..70e01809 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -9,4 +9,6 @@ from . import ( scene_object_collision_rules, scene_object_name, scene_object_dialogue_box_style, + scene_collision_rule_objects, + scene_object_names_list, ) diff --git a/routes/scenes/collision_rule_objects.py b/routes/scenes/collision_rule_objects.py new file mode 100644 index 00000000..8fd72911 --- /dev/null +++ b/routes/scenes/collision_rule_objects.py @@ -0,0 +1,61 @@ +import screens + +_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} +_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget") + + +def build_collision_rule_objects(objects): + """Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet + ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont + TOUJOURS les AUTRES objets qui réagissent à SA présence, voir + screens/rendering/collision_rules.py) — factorisé ici plutôt que + dupliqué : utilisé au premier rendu de la page (routes/scenes/ + scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après + ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/ + scene-editor.js) pour reconstruire la liste SANS recharger la page. + + `objects` doit déjà porter "rendered_html" (voir + screens.render_scene_object) sur chaque élément. + + resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans + rôle explicitement enregistré (voir screens/rendering/ + personnage_role.py — comportement voulu pour une scène à un seul + personnage, posé avant l'ajout de ce réglage). Appliqué tel quel, + ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite + (le cas de toute scène créée avant ce réglage — un personnage ajouté + depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite, + voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et + disparaîtraient donc TOUS de cette liste. On ne considère donc comme + "joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le + plus ancien, z_index croissant comme `objects`) — les suivants sans + rôle explicite sont traités comme "pnj" ici, sans rien réécrire en + base.""" + implicit_joueur_claimed = any( + (o.get("attributes") or {}).get("_personnage_role") == "joueur" + for o in objects if o["kind"] == "personnage" + ) + result = [] + for o in objects: + if o["kind"] in _COLLISION_EXCLUDED_KINDS: + continue + role = None + if o["kind"] == "personnage": + explicit_role = (o.get("attributes") or {}).get("_personnage_role") + if explicit_role in screens.PERSONNAGE_ROLES: + role = explicit_role + elif not implicit_joueur_claimed: + role = "joueur" + implicit_joueur_claimed = True + else: + role = "pnj" + if role == "joueur": + continue + result.append({ + "id": o["id"], + "name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"), + "kind": o["kind"], + "role": role, + "thumbnail_html": o["rendered_html"], + "rules": screens.resolve_collision_rules(o), + }) + return result diff --git a/routes/scenes/scene_collision_rule_objects.py b/routes/scenes/scene_collision_rule_objects.py new file mode 100644 index 00000000..1835b65b --- /dev/null +++ b/routes/scenes/scene_collision_rule_objects.py @@ -0,0 +1,24 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + +from .collision_rule_objects import build_collision_rule_objects + + +@app.route("/game//screens//collision-rule-objects", methods=["GET"]) +def scene_collision_rule_objects(slug, screen_id): + """Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS, + templates/scene_edit.html) — appelée en AJAX après un ajout/suppression + d'objet, ou un changement de rôle/nom (voir static/js/scenes/ + scene-editor.js) qui peut faire apparaître/disparaître un objet de + cette liste (rôle passé à/depuis "joueur", voir + build_collision_rule_objects) ou changer son libellé affiché (nom). + Recalculée entièrement plutôt que diffée : la liste reste petite (un + objet de scène par carte, jamais un "fond"/widget d'UI) et cette même + fonction est déjà appelée à chaque rendu complet de la page.""" + objects = screens.list_scene_objects(slug, screen_id) + for o in objects: + o["rendered_html"] = screens.render_scene_object(o) + return jsonify(build_collision_rule_objects(objects)) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 7ef00f2c..8e098a6c 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -5,6 +5,8 @@ from flask import render_template, request, abort, g import db import screens +from .collision_rule_objects import build_collision_rule_objects + def render_scene_edit(slug, screen_id, game): """Vue de l'éditeur de scène 2D — appelée par routes/screens/ @@ -22,22 +24,17 @@ def render_scene_edit(slug, screen_id, game): for o in objects: o["rendered_html"] = screens.render_scene_object(o) - # "Monde" (voir static/js/play/personnage-controller.js:: - # forgeUpdateSceneWorldSize, même principe côté serveur) : un "fond" - # posé à sa taille RÉELLE (souvent plus grande que la scène, voir - # add_scene_object.py) rend visible/joignable, dans CET éditeur, tout - # ce qui dépasse la scène nominale plutôt que de le rogner (le + # "Monde" (voir screens.resolve_scene_world_size, réutilisé aussi par + # la miniature de "Mes écrans", routes/games/game_dashboard.py) : un + # "fond" posé à sa taille RÉELLE (souvent plus grande que la scène, + # voir add_scene_object.py) rend visible/joignable, dans CET éditeur, + # tout ce qui dépasse la scène nominale plutôt que de le rogner (le # glisser-déposer le permet déjà depuis le fix précédent — sans # agrandir le canevas ici, cette partie restait invisible/injoignable # à la souris). Le rectangle "🎥 Champ de la caméra" (voir # templates/scene_edit.html) marque ensuite où s'arrête la scène # nominale À L'INTÉRIEUR de ce monde plus grand. - world_width, world_height = screen["scene_width"], screen["scene_height"] - for o in objects: - if o["kind"] == "fond": - world_width = max(world_width, o["x"] + o["width"]) - world_height = max(world_height, o["y"] + o["height"]) - world_width, world_height = int(world_width), int(world_height) + world_width, world_height = screens.resolve_scene_world_size(screen, objects) selected_id = request.args.get("selected", type=int) personnage_data = None @@ -66,28 +63,11 @@ def render_scene_edit(slug, screen_id, game): o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage" } - # "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet - # ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont - # TOUJOURS les AUTRES objets qui réagissent à SA présence, voir - # screens/rendering/collision_rules.py) — construit ici plutôt que - # réutiliser `objects` tel quel, pour ne poser resolve_personnage_role - # qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet - # SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages). - collision_rule_objects = [] - for o in objects: - if o["kind"] in ("fond", "dialogue_box", "quiz_box", "score_widget"): - continue - role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None - if role == "joueur": - continue - collision_rule_objects.append({ - "id": o["id"], - "name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"), - "kind": o["kind"], - "role": role, - "thumbnail_html": o["rendered_html"], - "rules": screens.resolve_collision_rules(o), - }) + # "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/ + # collision_rule_objects.py, factorisé pour être réutilisé par + # l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/ + # suppression/changement de rôle ou de nom sans recharger la page). + collision_rule_objects = build_collision_rule_objects(objects) flow_blocks = screens.list_flow_blocks(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id) diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py index e20131e9..86ec1a48 100644 --- a/routes/scenes/scene_object_add.py +++ b/routes/scenes/scene_object_add.py @@ -1,4 +1,4 @@ -from flask import request, redirect, url_for +from flask import request, redirect, url_for, jsonify import screens @@ -10,7 +10,20 @@ from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_back def scene_object_add(slug, screen_id): """Pose un nouvel objet sur la scène — mirror de routes/elements/ element_add.py, mais pour _scene_objects (voir screens/scenes/ - add_scene_object.py).""" + add_scene_object.py). + + Appelée en AJAX (voir addSceneObject(), static/js/scenes/scene-editor.js) + plutôt qu'en soumission de formulaire classique — sans ça, poser un + objet rechargeait TOUTE la page : perceptible comme un "vidage" de + l'éditeur de collision/quêtes le temps du rechargement, et perdait tout + état JS en cours (modale ouverte, etc.). Répond en JSON avec de quoi + ajouter l'objet au canevas SANS recharger — jamais sélectionné/ouvert + dans le panneau de propriétés pour autant (ça, voir + onSceneObjectMouseDown, reste un rechargement pour l'instant). La liste + "🧩 Collision" n'est PAS incluse ici : addSceneObject() la redemande + ensuite à routes/scenes/scene_collision_rule_objects.py (même endpoint + que pour un changement de rôle/nom, ou une suppression) plutôt que de + dupliquer ici la logique de build_collision_rule_objects().""" kind = request.form.get("kind", "personnage") forge_character = request.form.get("forge_character") background_slug = request.form.get("background_slug") @@ -21,7 +34,22 @@ def scene_object_add(slug, screen_id): new_id = screens.add_scene_object( slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug, ) + if request.headers.get("X-Requested-With") == "fetch": + obj = screens.get_scene_object(slug, new_id) + return jsonify({ + "id": new_id, "kind": obj["kind"], + "x": obj["x"], "y": obj["y"], "width": obj["width"], "height": obj["height"], + "rendered_html": screens.render_scene_object(obj), + # Relance l'aperçu animé tout de suite (voir personnage-preview.js + # ::restartPersonnageIdlePreview) — sans ça, un personnage + # fraîchement posé restait figé sur sa première frame jusqu'au + # prochain rechargement de page (qui n'arrive plus jamais, voir + # addSceneObject() côté JS). + "animations": screens.resolve_personnage_animations(obj) if obj["kind"] == "personnage" else None, + }) # Même route que l'éditeur document (/game//screens//edit, # endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la # vue de scène selon game_type, voir routes/screens/screen_edit.py. + # Conservé pour tout appelant non-JS (aucun connu actuellement, mais + # ne coûte rien à garder en secours). return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id)) diff --git a/routes/scenes/scene_object_delete.py b/routes/scenes/scene_object_delete.py index e91058ee..2ba26765 100644 --- a/routes/scenes/scene_object_delete.py +++ b/routes/scenes/scene_object_delete.py @@ -1,4 +1,4 @@ -from flask import redirect, url_for +from flask import redirect, request, url_for, jsonify import screens @@ -7,9 +7,24 @@ from core.flask_app import app @app.route("/game//scene-objects//delete", methods=["POST"]) def scene_object_delete(slug, object_id): + """Supprime un objet de scène — bouton 🗑️ du panneau de propriétés + (voir templates/scene_edit.html), toujours pour l'objet SÉLECTIONNÉ (le + seul dont ce bouton est affiché). + + Appelée en AJAX (deleteSceneObject(), static/js/scenes/scene-editor.js) + plutôt qu'en soumission de formulaire classique — sinon même + "rechargé en douceur" par pjax.js (qui intercepte tout
, voir son + commentaire d'en-tête), ça remplaçait TOUT
(canevas + onglets + Collision/Quêtes/Événements), perceptible comme un "vidage" de + l'éditeur le temps du remplacement.""" obj = screens.get_scene_object(slug, object_id) if not obj: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False}), 404 return redirect(url_for("index")) screen_id = obj["scene_id"] screens.delete_scene_object(slug, object_id) + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": True, "id": object_id}) + # Secours pour tout appelant non-JS (aucun connu actuellement). return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) diff --git a/routes/scenes/scene_object_names_list.py b/routes/scenes/scene_object_names_list.py new file mode 100644 index 00000000..92f63072 --- /dev/null +++ b/routes/scenes/scene_object_names_list.py @@ -0,0 +1,20 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-object-names", methods=["GET"]) +def scene_object_names_list(slug): + """Noms d'objets de scène à jour, pour repeupler le sélecteur "qui + parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON, + templates/scene_edit.html, questSpeakerOptions() dans static/js/ + quests/quest-editor.js) — cette variable n'est posée qu'une fois au + chargement complet de la page (HORS de #builder3, jamais rafraîchie + par une sélection d'objet sans rechargement, voir goToSelected()) : + sans cet endpoint, renommer un personnage puis rouvrir la modale de + dialogue dans la MÊME session ne faisait jamais apparaître son nouveau + nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre + temps — appelée en AJAX depuis saveSceneObjectName().""" + return jsonify(screens.list_named_scene_object_names(slug)) diff --git a/routes/screens/__init__.py b/routes/screens/__init__.py index 8fd2d9f4..f1ab84df 100644 --- a/routes/screens/__init__.py +++ b/routes/screens/__init__.py @@ -1 +1 @@ -from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, 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 diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 6e6de085..34f642f7 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -1,6 +1,4 @@ -import json - -from flask import render_template, request, abort, g +from flask import abort import db import screens @@ -15,124 +13,6 @@ def screen_edit(slug, screen_id): screen = screens.get_screen(slug, screen_id) if not screen: abort(404) - if screen["kind"] == "jeu_2d": - # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : - # le type d'écran ("document" ou "jeu_2d") est une propriété de - # L'ÉCRAN, pas du projet — même route/endpoint pour les deux, - # dispatch ici selon l'écran demandé — tout le reste de cette - # fonction ne concerne QUE l'éditeur document (comportement - # inchangé pour un écran "document"). - return render_scene_edit(slug, screen_id, game) - elements = screens.list_elements(slug, screen_id) - all_screens = screens.list_screens(slug) - element_types = screens.list_element_types(slug) - selected_id = request.args.get("selected", type=int) - selected_controls = None - personnage_data = None - if selected_id: - selected_el = next((e for e in elements if e["id"] == selected_id), None) - if selected_el: - template_definition_id = None - if screen.get("is_template"): - owning_type = screens.get_element_type_by_template_screen(slug, screen_id) - if owning_type: - template_definition_id = owning_type.get("bound_definition_id") - selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id) - if selected_el.get("widget") == "personnage": - # Phase 8 — panneau de propriétés sur-mesure (custom_panel, - # voir registry.py) : pas de controls génériques à afficher, - # juste la source (Forge/personnalisée) et son contenu. - personnage_data = screens.resolve_personnage_data(selected_el) - children_of = {} - for el in elements: - if el.get("parent_id"): - children_of.setdefault(el["parent_id"], []).append(el) - top_level_elements = [el for el in elements if not el.get("parent_id")] - elements_by_id = {el["id"]: el for el in elements} - - definitions = db.list_definitions(slug) - definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions} - # Phase 4 — nodeLabel() (static/js/screen_edit/flow-editor.js) affiche - # le nom de l'objet visé par un nœud "Ajouter une ligne" ; DEFINITIONS_DATA - # (ci-dessus) ne porte que lignes/champs, jamais le nom lui-même. - definitions_names = {d["id"]: d["name"] for d in definitions} - - # list_flow_blocks() rattache paresseusement les nœuds "orphelins" - # (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" — - # DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes - # garderait en mémoire un block_id périmé (None) pour ces nœuds. - flow_blocks = screens.list_flow_blocks(slug, screen_id) - flow_nodes = screens.list_flow_nodes(slug, screen_id) - flow_edges = screens.list_flow_edges(slug, screen_id) - elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements} - screens_map = {s["id"]: s["name"] for s in all_screens} - # Phase 8 — sélecteur d'animation à miniatures (nœud de flow "Jouer une - # animation" / clip de Timeline "sprite") : scopé aux SEULES animations - # de l'élément personnage réellement ciblé, jamais un catalogue global. - element_animations_map = { - el["id"]: screens.resolve_personnage_animations(el) for el in elements if el.get("widget") == "personnage" - } - animation_clips = screens.list_animation_clips(slug, screen_id) - global_variables = db.list_global_variables(slug) - custom_events = db.list_custom_events(slug) - custom_event_usages = screens.list_custom_event_usages(slug) - custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events} - - blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels) - - # Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au - # rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan - # "Bibliothèque de sprites animaux CraftPix") — un compte "user" ne - # doit même pas les voir dans la galerie. - sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY - sprite_library_families = screens.sprite_gallery_families(sprite_library) - - return render_template( - "screen_edit.html", - game=game, screen=screen, elements=elements, top_level_elements=top_level_elements, - children_of=children_of, container_widgets=screens.CONTAINER_WIDGETS, - elements_by_id=elements_by_id, - all_screens=all_screens, - element_types=element_types, widgets=screens.WIDGETS, - trigger_labels=screens.TRIGGER_LABELS, action_type_labels=screens.ACTION_TYPE_LABELS, - element_action_properties=screens.ELEMENT_ACTION_PROPERTIES, - element_action_property_labels=screens.ELEMENT_ACTION_PROPERTY_LABELS, - element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES, - element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS, - element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES, - element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES, - element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES, - sprite_library=sprite_library, sprite_library_families=sprite_library_families, - sprite_library_json=json.dumps(sprite_library), - personnage_data=personnage_data, - element_animations_map_json=json.dumps(element_animations_map), - data_operations=screens.DATA_OPERATIONS, - data_operation_labels=screens.DATA_OPERATION_LABELS, - score_status_choices=db.SCORE_STATUS_CHOICES, - score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS), - definitions=definitions, definitions_json=json.dumps(definitions_data), - selected_id=selected_id, selected_controls=selected_controls, - flow_nodes=flow_nodes, flow_edges=flow_edges, - flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges), - trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS, - elements_labels_json=json.dumps(elements_labels), screens_map_json=json.dumps(screens_map), - action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS), - element_action_property_labels_json=json.dumps(screens.ELEMENT_ACTION_PROPERTY_LABELS), - element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS), - element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS), - element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_LABELS), - element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS), - data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS), - condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS), - trigger_labels_json=json.dumps(screens.TRIGGER_LABELS), - animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips), - animate_css_catalog=screens.ANIMATE_CSS_CATALOG, - animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), - fontawesome_icons=screens.FONTAWESOME_ICONS, - global_variables=global_variables, - custom_events=custom_events, custom_event_usages=custom_event_usages, - custom_events_map_json=json.dumps(custom_events_map), - blocks_view=blocks_view, - flow_blocks_json=json.dumps(flow_blocks), - definitions_names_json=json.dumps(definitions_names), - ) + # Un seul moteur d'écran désormais (jeu_2d) — voir + # screens/screens_repo/ensure_schema.py. + return render_scene_edit(slug, screen_id, game) diff --git a/routes/screens/screen_set_aspect.py b/routes/screens/screen_set_aspect.py deleted file mode 100644 index d433cb95..00000000 --- a/routes/screens/screen_set_aspect.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import request, jsonify - -import screens - -from core.flask_app import app - - -@app.route("/game//screens//aspect", methods=["POST"]) -def screen_set_aspect(slug, screen_id): - """Mémorise le format d'aperçu (Portrait/Paysage/Carré) choisi pour cet - écran, en AJAX — sans recharger la page, pour ne jamais perdre ce choix - au prochain ajout/suppression d'élément ou changement de sélection (qui - redemandent le panneau au serveur).""" - body = request.get_json(silent=True) or {} - aspect = body.get("aspect", "") - screens.set_screen_aspect(slug, screen_id, aspect) - return jsonify({"ok": True}) diff --git a/routes/screens/screens_new.py b/routes/screens/screens_new.py index cab0bdc1..895a4fc3 100644 --- a/routes/screens/screens_new.py +++ b/routes/screens/screens_new.py @@ -9,18 +9,9 @@ from core.flask_app import app @app.route("/game//screens/new", methods=["POST"]) def screens_new(slug): name = request.form.get("name", "").strip() - if db.is_restricted(slug): - # Dashboard simplifié (voir routes/games/game_dashboard.py) : pas - # de sélecteur de type dans son formulaire "+ Ajouter un écran" — - # imposé par le projet (jeu_2d pour un RPG, document pour les - # autres), même valeur pour TOUS ses écrans (voir - # db/games/game_type_catalog.py). - onboarding_type = db.get_onboarding_type(slug) - kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"] - else: - kind = request.form.get("kind", "document") - if kind not in ("document", "jeu_2d"): - kind = "document" + # Un seul moteur d'écran désormais (jeu_2d) — imposé à tout écran créé, + # quel que soit le projet (voir db/games/game_type_catalog.py). + kind = "jeu_2d" if name: screen_id = screens.create_screen(slug, name, kind=kind) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) diff --git a/screens/__init__.py b/screens/__init__.py index fb9463d1..96c7ac40 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -1,34 +1,15 @@ """ -screens.py — écrans de jeu : éléments posés en glisser-déposer sur un -écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans, -et actions déclenchées par clic ou soumission de formulaire. - -Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de -WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de -données...), chacun avec ses propres réglages ("controls") : un réglage = -un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom -de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget -correspond à une vraie balise HTML avec de vrais attributs et un vrai -style CSS ; l'utilisateur ne les manipule jamais directement. +screens.py — écrans de jeu : un seul moteur d'écran, la scène 2D "jeu_2d" +(voir screens/scenes/) — objets posés en pixels, enchaînement des écrans, +et logique déclenchée par clic/collision/clavier/minuteur (voir +screens/flow/). Tout est stocké dans la même base SQLite que le reste du jeu (projects//game.db) : - - _screens : un écran du jeu (accueil, formulaire, scène...) - - _element_types : le "catalogue" des éléments de jeu réutilisables - (Personnage, Outil, IA...) que l'utilisateur crée - - _screen_elements : chaque élément posé sur un écran (widget, position - et taille en POURCENTAGE de l'écran, contenu, - attributs et style — dérivés des réglages du - widget, jamais saisis en texte libre) - - _actions : ce qui se passe au clic / à la soumission d'un - élément (écran suivant, écran précédent, aller à - un écran précis, ne rien faire) - -Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu -fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche -automatiquement une ligne par donnée existante — par exemple, tous les -niveaux d'un parcours — à partir d'un modèle de texte avec des -{{nom_de_champ}}. + - _screens : un écran du jeu (une scène...) + - _scene_objects : chaque objet posé sur une scène (personnage, + décor, fond, widgets d'interface...) + - _flow_nodes/_flow_edges : le graphe de logique de chaque écran Ce fichier est désormais un package (voir les sous-dossiers) organisé selon "un fichier = une fonction, un dossier = une responsabilité" ; ce @@ -37,20 +18,10 @@ utilisée par app.py et les templates, afin que `import screens` continue de fonctionner à l'identique. """ -from .widgets.registry import WIDGETS -from .widgets.container_widgets import CONTAINER_WIDGETS -from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS -from .widgets.fontawesome_icons import FONTAWESOME_ICONS -from .widgets.widget_meta import widget_meta -from .widgets.controls_with_values import controls_with_values -from .widgets.data_definition_options import data_definition_options +from .data_definitions.data_definition_options import data_definition_options -from .labels.trigger_labels import TRIGGER_LABELS from .labels.action_type_labels import ACTION_TYPE_LABELS from .labels.element_action_properties import ( - ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS, - ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS, - ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS, ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS, ) from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS @@ -78,7 +49,6 @@ from .flow.constants import ( TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID, ) -from .rendering.render_element_html import render_element_html from .rendering.trigger_for import _trigger_for from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE @@ -99,27 +69,13 @@ from .screens_repo.rename_screen import rename_screen from .screens_repo.delete_screen import delete_screen from .screens_repo.move_screen import move_screen from .screens_repo.screens_referencing import screens_referencing -from .screens_repo.set_screen_aspect import set_screen_aspect from .screens_repo.set_scene_size import set_scene_size from .screens_repo.set_screen_background_music import set_screen_background_music -from .elements.list_elements import list_elements -from .elements.get_element import get_element -from .elements.add_element import add_element -from .elements.add_tab import add_tab -from .elements.update_element_geometry import update_element_geometry -from .elements.reorder_element import reorder_element -from .elements.move_element_to_container import move_element_to_container -from .elements.save_element_controls import save_element_controls -from .elements.delete_element import delete_element -from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element -from .elements.duplicate_element import duplicate_element -from .elements.rename_element import rename_element -from .elements.set_personnage_data import set_personnage_data - from .scenes.ensure_scene_schema import ensure_scene_schema from .scenes.add_scene_object import add_scene_object from .scenes.list_scene_objects import list_scene_objects +from .scenes.resolve_scene_world_size import resolve_scene_world_size from .scenes.get_scene_object import get_scene_object from .scenes.update_scene_object_geometry import update_scene_object_geometry from .scenes.delete_scene_object import delete_scene_object @@ -132,19 +88,6 @@ from .scenes.set_scene_object_name import set_scene_object_name from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style from .scenes.render_scene_object import render_scene_object from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES -from .elements.element_label import element_label - -from .element_types.list_element_types import list_element_types -from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen -from .element_types.create_element_type import create_element_type -from .element_types.update_element_type import update_element_type -from .element_types.element_type_usage_count import element_type_usage_count -from .element_types.delete_element_type import delete_element_type - -from .legacy_actions.list_actions import list_actions -from .legacy_actions.add_action import add_action -from .legacy_actions.get_action import get_action -from .legacy_actions.delete_action import delete_action from .flow.ensure_flow_schema import ensure_flow_schema from .flow.list_flow_nodes import list_flow_nodes diff --git a/screens/widgets/data_definition_options.py b/screens/data_definitions/data_definition_options.py similarity index 100% rename from screens/widgets/data_definition_options.py rename to screens/data_definitions/data_definition_options.py diff --git a/screens/element_types/__init__.py b/screens/element_types/__init__.py deleted file mode 100644 index 44558eab..00000000 --- a/screens/element_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...).""" diff --git a/screens/element_types/create_element_type.py b/screens/element_types/create_element_type.py deleted file mode 100644 index f1e4a49e..00000000 --- a/screens/element_types/create_element_type.py +++ /dev/null @@ -1,21 +0,0 @@ -import db - -from ..screens_repo.ensure_schema import ensure_schema -from ..screens_repo.create_screen import create_screen - - -def create_element_type(slug, name, icon, bound_definition_id=None): - """Un élément de jeu est un conteneur réutilisable : sa "recette" (les - éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle - caché, créé ici en même temps que l'entrée de catalogue — on le - remplit ensuite exactement comme le contenu de n'importe quel - conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle.""" - ensure_schema(slug) - template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True) - conn = db.connect(slug) - conn.execute( - "INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)", - (name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None), - ) - conn.commit() - conn.close() diff --git a/screens/element_types/delete_element_type.py b/screens/element_types/delete_element_type.py deleted file mode 100644 index 3e4c47b8..00000000 --- a/screens/element_types/delete_element_type.py +++ /dev/null @@ -1,15 +0,0 @@ -import db - - -def delete_element_type(slug, element_type_id): - conn = db.connect(slug) - row = conn.execute( - "SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,) - ).fetchone() - conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,)) - if row and row["template_screen_id"]: - # Supprime aussi l'écran-modèle caché associé — son propre contenu - # (_screen_elements) part avec lui via ON DELETE CASCADE. - conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],)) - conn.commit() - conn.close() diff --git a/screens/element_types/element_type_usage_count.py b/screens/element_types/element_type_usage_count.py deleted file mode 100644 index acfc7533..00000000 --- a/screens/element_types/element_type_usage_count.py +++ /dev/null @@ -1,10 +0,0 @@ -import db - - -def element_type_usage_count(slug, element_type_id): - conn = db.connect(slug) - count = conn.execute( - "SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,) - ).fetchone()["c"] - conn.close() - return count diff --git a/screens/element_types/get_element_type_by_template_screen.py b/screens/element_types/get_element_type_by_template_screen.py deleted file mode 100644 index 1533cced..00000000 --- a/screens/element_types/get_element_type_by_template_screen.py +++ /dev/null @@ -1,16 +0,0 @@ -import db - -from ..screens_repo.ensure_schema import ensure_schema - - -def get_element_type_by_template_screen(slug, screen_id): - """L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST - cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur - d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu - (bound_definition_id, réglé à la création — voir element_types.html) - sans devoir tout lister soi-même.""" - ensure_schema(slug) - conn = db.connect(slug) - row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone() - conn.close() - return dict(row) if row else None diff --git a/screens/element_types/is_overlay_only.py b/screens/element_types/is_overlay_only.py deleted file mode 100644 index bc611662..00000000 --- a/screens/element_types/is_overlay_only.py +++ /dev/null @@ -1,16 +0,0 @@ -from .load_template_tree import _load_template_tree - - -def _is_overlay_only_element_type(slug, element_type_id): - """True si le modèle d'un élément de jeu N'EST QU'une "Superposition / - boîte de dialogue" (un seul élément de premier niveau, de ce widget) — - utilisé à deux endroits qui doivent s'accorder : render_element_html.py - (court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui - resterait sinon visible en permanence) et element_style_filter.py (le - cadre .canvasElement/.playElement de cet exemplaire ne doit pas non - plus poser de z-index, pour la même raison qu'une superposition posée - directement — voir son propre commentaire).""" - template_tree = _load_template_tree(slug, element_type_id) - if not template_tree: - return False - return {c.get("widget") for c in template_tree["top"]} == {"superposition"} diff --git a/screens/element_types/list_element_types.py b/screens/element_types/list_element_types.py deleted file mode 100644 index f2ddc769..00000000 --- a/screens/element_types/list_element_types.py +++ /dev/null @@ -1,19 +0,0 @@ -import json - -import db - -from ..screens_repo.ensure_schema import ensure_schema - - -def list_element_types(slug): - ensure_schema(slug) - conn = db.connect(slug) - rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall() - conn.close() - result = [] - for r in rows: - d = dict(r) - d["default_style"] = json.loads(d["default_style"] or "{}") - d["default_attributes"] = json.loads(d["default_attributes"] or "{}") - result.append(d) - return result diff --git a/screens/element_types/load_template_tree.py b/screens/element_types/load_template_tree.py deleted file mode 100644 index 630984a4..00000000 --- a/screens/element_types/load_template_tree.py +++ /dev/null @@ -1,49 +0,0 @@ -import json - -import db - - -def _load_template_tree(slug, element_type_id): - """Charge le contenu (éléments de premier niveau + descendants) de - l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier - quoi que ce soit en base) — utilisé quand un Répéteur de données affiche - cet élément de jeu comme modèle de ligne (voir _render_repeater) : on - relit et on rejoue son arbre à chaque ligne de données, avec les - {{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou - plus) d'écran-modèle valide.""" - conn = db.connect(slug) - et_row = conn.execute( - "SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,) - ).fetchone() - if not et_row or not et_row["template_screen_id"]: - conn.close() - return None - rows = conn.execute( - "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", - (et_row["template_screen_id"],), - ).fetchall() - conn.close() - elements = [] - for r in rows: - d = dict(r) - d["style"] = json.loads(d["style"] or "{}") - d["attributes"] = json.loads(d["attributes"] or "{}") - elements.append(d) - children_map = {} - for d in elements: - if d.get("parent_id"): - children_map.setdefault(d["parent_id"], []).append(d) - top = [d for d in elements if not d.get("parent_id")] - if not top: - return None - # Astuce de rendu : ces éléments sont "de premier niveau" dans leur - # écran-modèle (parent_id NULL en base, donc positionnés en absolu par - # défaut), mais une fois le modèle réutilisé ailleurs (ligne de - # Répéteur), ils doivent se comporter comme des enfants en flux normal, - # pas comme des boîtes positionnées en absolu sur un écran plein. On leur - # donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien - # que pour ce rendu éphémère — _style_string ne teste que sa présence, - # jamais sa valeur exacte, et cette copie n'est jamais enregistrée. - for t in top: - t["parent_id"] = t.get("parent_id") or "template-root" - return {"top": top, "children_map": children_map} diff --git a/screens/element_types/update_element_type.py b/screens/element_types/update_element_type.py deleted file mode 100644 index 7ac380a1..00000000 --- a/screens/element_types/update_element_type.py +++ /dev/null @@ -1,13 +0,0 @@ -import db - - -def update_element_type(slug, element_type_id, name=None, bound_definition_id=None): - conn = db.connect(slug) - if name: - conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id)) - conn.execute( - "UPDATE _element_types SET bound_definition_id = ? WHERE id = ?", - (int(bound_definition_id) if bound_definition_id else None, element_type_id), - ) - conn.commit() - conn.close() diff --git a/screens/elements/__init__.py b/screens/elements/__init__.py deleted file mode 100644 index 8f3407b2..00000000 --- a/screens/elements/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Éléments posés sur un écran (_screen_elements).""" diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py deleted file mode 100644 index e1e99b1b..00000000 --- a/screens/elements/add_element.py +++ /dev/null @@ -1,87 +0,0 @@ -import json - -import db - -from ..widgets.registry import WIDGETS -from ..widgets.widget_meta import widget_meta -from ..widgets.default_style_for_widget import default_style_and_attributes -from ..element_types.list_element_types import list_element_types -from ..element_types.is_overlay_only import _is_overlay_only_element_type - - -def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None): - """parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur - de cet élément parent (qui doit être un conteneur/répéteur/groupe de - champs) plutôt que directement sur l'écran — sa position/taille ne sert - alors plus (il suit le flux du parent), mais on garde des valeurs par - défaut pour rester cohérent si jamais il en sort un jour. - - element_type_id : si fourni, un SEUL élément est créé ici (ligne plate, - comme n'importe quel widget), qui garde juste une référence vers cet - élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS - copié en base : il est rechargé EN DIRECT à chaque affichage (voir - render_element_html.py), exactement comme un modèle de ligne de - Répéteur (voir _load_template_tree/_render_repeater). Modifier - l'élément de jeu dans son propre éditeur met donc à jour tous ses - exemplaires déjà posés, sur n'importe quel écran, sans avoir à les - retoucher un par un — au prix de ne plus pouvoir personnaliser - individuellement le CONTENU d'un exemplaire précis (sa position/taille - sur l'écran, elles, restent propres à chaque exemplaire, comme - d'habitude).""" - widget_key = widget_key if widget_key in WIDGETS else "conteneur" - if element_type_id: - types = {t["id"]: t for t in list_element_types(slug)} - et = types.get(int(element_type_id)) - if et: - widget_key = et.get("default_widget") or widget_key - meta = widget_meta(widget_key) - # Réglages dont la valeur par défaut a un effet visuel voulu dès la - # création (ex: une image démarre avec "cover" pour se recadrer - # proprement) — hors couleurs, qui restent volontairement transparentes/ - # sans bordure tant qu'on ne les choisit pas explicitement. - attributes, style = default_style_and_attributes(widget_key) - if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)): - # Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte - # de dialogue" (render_element_html.py décide alors, au rendu, de - # faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) : - # démarre masqué par défaut, exactement comme une superposition - # posée directement (voir default_style_for_widget.py) — sinon un - # dialogue fraîchement déposé couvrirait tout l'écran en jeu dès - # sa pose, avant même qu'une action ne l'affiche. - style["display"] = "none" - content = meta.get("default_content", "") - width, height = meta.get("w", 30), meta.get("h", 10) - - conn = db.connect(slug) - max_z = conn.execute( - "SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,) - ).fetchone()["m"] - # Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT - # SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le - # précédent et le recouvre entièrement — ce qui peut donner l'impression - # trompeuse qu'un "élément indésirable" vient d'apparaître et d'en - # cacher un autre (le plus souvent après une confusion entre "Ajouter un - # élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous - # les deux un nouvel élément mais pas au même endroit dans l'arbre). - x = y = 10 - if not parent_id: - top_level_count = conn.execute( - "SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL", - (screen_id,), - ).fetchone()["c"] - offset = (top_level_count % 8) * 4 - x = y = 10 + offset - conn.execute( - """INSERT INTO _screen_elements - (screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes), - x, y, width, height, (max_z or 0) + 1, - json.dumps(style), - ), - ) - element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] - conn.commit() - conn.close() - return element_id diff --git a/screens/elements/add_tab.py b/screens/elements/add_tab.py deleted file mode 100644 index 8b0036d1..00000000 --- a/screens/elements/add_tab.py +++ /dev/null @@ -1,19 +0,0 @@ -import db - -from .add_element import add_element -from .rename_element import rename_element - - -def add_tab(slug, onglets_id, screen_id): - """Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur - posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut - (N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet, - modifiable ensuite comme celui de n'importe quel élément.""" - conn = db.connect(slug) - count = conn.execute( - "SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,) - ).fetchone()["c"] - conn.close() - tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id) - rename_element(slug, tab_id, f"Onglet {count + 1}") - return tab_id diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py deleted file mode 100644 index c187d7da..00000000 --- a/screens/elements/delete_element.py +++ /dev/null @@ -1,50 +0,0 @@ -import db - -from ..animations.ensure_animation_schema import ensure_animation_schema -from ..flow.ensure_flow_schema import ensure_flow_schema -from .element_descendant_ids import element_descendant_ids - - -def delete_element(slug, element_id): - ensure_flow_schema(slug) - ensure_animation_schema(slug) - conn = db.connect(slug) - row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() - if not row: - conn.close() - return - ids = element_descendant_ids(conn, row["screen_id"], element_id) - placeholders = ",".join("?" * len(ids)) - # Un nœud de la Logique de la scène (déclencheur "clic sur cet - # élément"/action "Modifier cet élément"/condition de collision — voir - # ci-dessous...) ou une ancienne action du système _actions (conservé - # pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES - # DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces - # colonnes (trigger_element_id/target_element_id) n'ont volontairement - # PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être - # supprimé "par erreur" en cascade depuis un nœud de logique qu'on - # modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir - # db/connection.py) fait échouer la suppression elle-même avec - # "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour - # prévenir l'utilisateur AVANT qu'il confirme, plutôt que de supprimer - # ces nœuds en silence. - # - # cond_element_a/cond_element_b (Phase 5, condition de collision) : ni - # contrainte FK ni ON DELETE CASCADE (voir ensure_flow_schema.py), mais - # un nœud qui compare le rectangle de CET élément à un autre n'a pas - # plus de sens que si c'était trigger_element_id/target_element_id — - # supprimé ici pour la même raison, pas parce que la base l'exigerait. - conn.execute( - f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) - OR target_element_id IN ({placeholders}) - OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""", - ids + ids + ids + ids, - ) - conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids) - # _animation_clips.element_id n'a plus de contrainte FK (voir - # ensure_animation_schema.py — réutilisé aussi par les objets de scène - # jeu_2d) : nettoyage manuel, même raison que ci-dessus. - conn.execute(f"DELETE FROM _animation_clips WHERE element_id IN ({placeholders})", ids) - conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,)) - conn.commit() - conn.close() diff --git a/screens/elements/duplicate_element.py b/screens/elements/duplicate_element.py deleted file mode 100644 index ecd20e0b..00000000 --- a/screens/elements/duplicate_element.py +++ /dev/null @@ -1,60 +0,0 @@ -import db - - -def duplicate_element(slug, element_id): - """Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL - (même écran, même parent_id que l'original) — le clic droit "Dupliquer" - de l'arborescence n'a pas à faire choisir où poser la copie, elle - apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à - sélectionner ensuite), ou None si l'élément n'existe pas.""" - conn = db.connect(slug) - root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() - if not root_row: - conn.close() - return None - root = dict(root_row) - source = [ - dict(r) for r in conn.execute( - "SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],) - ).fetchall() - ] - conn.close() - - def copy_one(src, new_parent_id, is_root): - conn = db.connect(slug) - max_z = conn.execute( - "SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],) - ).fetchone()["m"] - x, y = src["x"], src["y"] - if is_root and not new_parent_id: - # Petit décalage pour que la copie ne recouvre pas exactement - # l'original (qui serait alors invisible en dessous). - x = min(100, (x or 0) + 4) - y = min(100, (y or 0) + 4) - name = src.get("name") - if is_root and name: - name = f"{name} (copie)" - conn.execute( - """INSERT INTO _screen_elements - (screen_id, parent_id, widget, element_type_id, name, content, attributes, - x, y, width, height, z_index, style) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name, - src["content"], src["attributes"], x, y, src["width"], src["height"], - (max_z or 0) + 1, src["style"], - ), - ) - new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] - conn.commit() - conn.close() - return new_id - - def walk(src_parent_id, new_parent_id): - for child in [s for s in source if s.get("parent_id") == src_parent_id]: - new_child_id = copy_one(child, new_parent_id, is_root=False) - walk(child["id"], new_child_id) - - new_root_id = copy_one(root, root.get("parent_id"), is_root=True) - walk(root["id"], new_root_id) - return new_root_id diff --git a/screens/elements/element_descendant_ids.py b/screens/elements/element_descendant_ids.py deleted file mode 100644 index aa10b3cb..00000000 --- a/screens/elements/element_descendant_ids.py +++ /dev/null @@ -1,23 +0,0 @@ -def element_descendant_ids(conn, screen_id, root_id): - """Id de cet élément et de TOUS ses descendants — ceux qu'une - suppression en cascade (parent_id ON DELETE CASCADE, voir - ensure_schema.py) supprime automatiquement en même temps que lui. - Partagé entre delete_element.py (nettoyage des références de la - Logique de la scène avant suppression) et flow_nodes_referencing_ - element.py (pour prévenir l'utilisateur AVANT qu'il confirme la - suppression, voir routes/elements/element_delete_impact.py).""" - rows = conn.execute( - "SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,) - ).fetchall() - children_of = {} - for r in rows: - if r["parent_id"]: - children_of.setdefault(r["parent_id"], []).append(r["id"]) - ids = [root_id] - stack = [root_id] - while stack: - current = stack.pop() - for child_id in children_of.get(current, []): - ids.append(child_id) - stack.append(child_id) - return ids diff --git a/screens/elements/element_label.py b/screens/elements/element_label.py deleted file mode 100644 index 523132b5..00000000 --- a/screens/elements/element_label.py +++ /dev/null @@ -1,10 +0,0 @@ -from ..widgets.registry import WIDGETS - - -def element_label(el, widgets_catalog=None): - """Nom lisible d'un élément pour les listes/menus : son nom personnalisé - s'il en a un, sinon le libellé de son widget (ex: "Image").""" - catalog = widgets_catalog if widgets_catalog is not None else WIDGETS - if el.get("name"): - return el["name"] - return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?")) diff --git a/screens/elements/flow_nodes_referencing_element.py b/screens/elements/flow_nodes_referencing_element.py deleted file mode 100644 index 9231149a..00000000 --- a/screens/elements/flow_nodes_referencing_element.py +++ /dev/null @@ -1,30 +0,0 @@ -import db - -from ..flow.ensure_flow_schema import ensure_flow_schema -from .element_descendant_ids import element_descendant_ids - - -def flow_nodes_referencing_element(slug, element_id): - """Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet - élément", action "Modifier cet élément"/"Activer cet onglet", condition - de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES - DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément - (voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT - qu'il confirme la suppression (voir la modale de confirmation dans - screen_edit.html), plutôt que de les supprimer en silence.""" - ensure_flow_schema(slug) - conn = db.connect(slug) - row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() - if not row: - conn.close() - return 0 - ids = element_descendant_ids(conn, row["screen_id"], element_id) - placeholders = ",".join("?" * len(ids)) - count = conn.execute( - f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) - OR target_element_id IN ({placeholders}) - OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""", - ids + ids + ids + ids, - ).fetchone()["c"] - conn.close() - return count diff --git a/screens/elements/get_element.py b/screens/elements/get_element.py deleted file mode 100644 index 03059904..00000000 --- a/screens/elements/get_element.py +++ /dev/null @@ -1,15 +0,0 @@ -import json - -import db - - -def get_element(slug, element_id): - conn = db.connect(slug) - row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() - conn.close() - if not row: - return None - d = dict(row) - d["style"] = json.loads(d["style"] or "{}") - d["attributes"] = json.loads(d["attributes"] or "{}") - return d diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py deleted file mode 100644 index 38befc29..00000000 --- a/screens/elements/list_elements.py +++ /dev/null @@ -1,95 +0,0 @@ -import json - -import db - -from ..rendering.render_element_html import render_element_html -from ..element_types.is_overlay_only import _is_overlay_only_element_type - - -def list_elements(slug, screen_id, enforce_visibility=False, player_id=db.PLAYER_SHARED): - """Renvoie TOUS les éléments de l'écran, à plat (parent_id indique - l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE - élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" : - celui d'un conteneur/répéteur/groupe de champs inclut, en le générant - récursivement, le HTML de tout ce qui est posé à l'intérieur — un - élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le - rendered_html de son ancêtre de premier niveau, utilisé pour le rendu - HTML initial de la page ; une fois dans le sien propre) : c'est ce - dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/ - refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué - (ex. un panneau de détail posé dans un conteneur) sans reconstruire tout - son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce - changement, seuls les éléments de premier niveau recevaient un - rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui - retombait donc toujours sur la régénération de son ancêtre de premier - niveau, quelle que soit la finesse du ciblage côté JS. - - enforce_visibility : True uniquement pour le mode jouable (voir - full_game_payload.py) — applique la "condition de visibilité" de chaque - élément (voir visibility_condition.py). Toujours False pour l'éditeur, - qui doit continuer à montrer/sélectionner un élément même si sa - condition serait actuellement fausse. - - player_id (état par joueur, Phase 1) : posé dans ctx["_forge_player_id"] - pour tout le rendu en aval (Répéteur/Jauge/Donnée liée/condition de - visibilité) — quel joueur consulte cet écran, pour qu'un objet/une - variable per_player affiche SA valeur (voir db/global_vars/, - db/rows/).""" - conn = db.connect(slug) - rows = conn.execute( - "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,) - ).fetchall() - conn.close() - result = [] - for r in rows: - d = dict(r) - d["style"] = json.loads(d["style"] or "{}") - d["attributes"] = json.loads(d["attributes"] or "{}") - result.append(d) - - # Descendant d'un exemplaire d'élément de jeu (element_type_id réglé sur - # un ancêtre) : un ancien enfant copié en base par l'ex-instantiate_ - # template_tree (mécanisme retiré, voir add_element.py) — son contenu - # vient désormais TOUJOURS en direct de l'écran-modèle au moment du - # rendu (voir render_element_html.py/_render_element_type_children), - # jamais de ces lignes, qui peuvent encore traîner en base pour un - # exemplaire posé avant ce changement. Exclu ici pour ne plus jamais - # apparaître sélectionnable dans l'arborescence de l'éditeur — sinon - # ses réglages, plus jamais synchronisés avec le modèle, donnaient - # l'impression trompeuse que "les propriétés ne sont pas prises en - # compte" (une taille de texte modifiée dans le modèle ne se reflétant - # jamais sur cette ligne fantôme, toujours sélectionnable à l'écran). - by_id = {d["id"]: d for d in result} - - def _has_linked_ancestor(d): - seen = set() - parent_id = d.get("parent_id") - while parent_id and parent_id not in seen: - seen.add(parent_id) - parent = by_id.get(parent_id) - if not parent: - return False - if parent.get("element_type_id"): - return True - parent_id = parent.get("parent_id") - return False - - result = [d for d in result if not _has_linked_ancestor(d)] - - children_map = {} - for d in result: - if d.get("parent_id"): - children_map.setdefault(d["parent_id"], []).append(d) - play_ctx = {"_forge_play_mode": True, "_forge_player_id": player_id} if enforce_visibility else None - for d in result: - # Un exemplaire de premier niveau d'un élément de jeu dont le - # modèle N'EST QU'une superposition : son cadre de positionnement - # (.canvasElement/.playElement, voir filters/element_style_filter.py) - # ne doit pas non plus poser de z-index, pour la même raison qu'une - # superposition posée directement (sinon elle crée un contexte - # d'empilement CSS qui piège le z-index:9999 de la superposition - # rendue à l'intérieur — voir element_style_filter.py). - if d.get("element_type_id") and not d.get("parent_id"): - d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"]) - d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx) - return result diff --git a/screens/elements/move_element_to_container.py b/screens/elements/move_element_to_container.py deleted file mode 100644 index 17e6bf67..00000000 --- a/screens/elements/move_element_to_container.py +++ /dev/null @@ -1,62 +0,0 @@ -import db - -from ..widgets.container_widgets import CONTAINER_WIDGETS - - -def move_element_to_container(slug, element_id, new_parent_id): - """Déplace element_id pour qu'il devienne un enfant de new_parent_id - (glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en - DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme - d'habitude par glisser-déposer entre frères et sœurs (voir - reorder_element.py). Refuse silencieusement (ne touche à rien) si : - new_parent_id n'est pas un widget conteneur, si c'est element_id - lui-même, ou si c'est un de ses propres descendants (ça créerait un - cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même).""" - conn = db.connect(slug) - rows = conn.execute( - "SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)", - (element_id, new_parent_id), - ).fetchall() - by_id = {r["id"]: r for r in rows} - el = by_id.get(element_id) - target = by_id.get(new_parent_id) - if not el or not target or el["screen_id"] != target["screen_id"]: - conn.close() - return - if target["widget"] not in CONTAINER_WIDGETS: - conn.close() - return - if new_parent_id == element_id: - conn.close() - return - - # Cycle : new_parent_id ne doit pas être element_id, ni un descendant de - # element_id (direct ou indirect) — sinon element_id finirait posé à - # l'intérieur de sa propre arborescence. - children_of = {} - for r in conn.execute( - "SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],) - ).fetchall(): - if r["parent_id"]: - children_of.setdefault(r["parent_id"], []).append(r["id"]) - stack = list(children_of.get(element_id, [])) - descendants = set() - while stack: - node = stack.pop() - if node in descendants: - continue - descendants.add(node) - stack.extend(children_of.get(node, [])) - if new_parent_id in descendants: - conn.close() - return - - max_z = conn.execute( - "SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],) - ).fetchone()["m"] - conn.execute( - "UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?", - (new_parent_id, (max_z or 0) + 1, element_id), - ) - conn.commit() - conn.close() diff --git a/screens/elements/rename_element.py b/screens/elements/rename_element.py deleted file mode 100644 index 94026f46..00000000 --- a/screens/elements/rename_element.py +++ /dev/null @@ -1,8 +0,0 @@ -import db - - -def rename_element(slug, element_id, name): - conn = db.connect(slug) - conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id)) - conn.commit() - conn.close() diff --git a/screens/elements/reorder_element.py b/screens/elements/reorder_element.py deleted file mode 100644 index 5c926e56..00000000 --- a/screens/elements/reorder_element.py +++ /dev/null @@ -1,37 +0,0 @@ -import db - - -def reorder_element(slug, screen_id, element_id, target_id, position): - """Déplace element_id juste avant ("before") ou juste après ("after") - target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même - parent_id — sinon on ne touche à rien, pour ne jamais reparenter par - erreur via le glisser-déposer de l'arborescence). L'ordre est porté par - z_index, partagé par tous les éléments de l'écran (pas juste les frères - et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel - (z_index, id) pour rester cohérent partout, pas seulement dans ce - conteneur.""" - conn = db.connect(slug) - rows = conn.execute( - "SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", - (screen_id,), - ).fetchall() - by_id = {r["id"]: r["parent_id"] for r in rows} - if element_id not in by_id or target_id not in by_id: - conn.close() - return - if by_id[element_id] != by_id[target_id]: - conn.close() - return - - order = [r["id"] for r in rows] - order.remove(element_id) - target_index = order.index(target_id) - insert_at = target_index + 1 if position == "after" else target_index - order.insert(insert_at, element_id) - - conn.executemany( - "UPDATE _screen_elements SET z_index = ? WHERE id = ?", - [(i + 1, eid) for i, eid in enumerate(order)], - ) - conn.commit() - conn.close() diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py deleted file mode 100644 index 2abb9587..00000000 --- a/screens/elements/save_element_controls.py +++ /dev/null @@ -1,227 +0,0 @@ -import json - -import db - -from ..widgets.widget_meta import widget_meta -from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS -from ..clause_list_codec import encode_clauses -from .get_element import get_element - - -def _hex_to_rgb(hex_color): - h = (hex_color or "").lstrip("#") - if len(h) != 6: - return (0, 0, 0) - try: - return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) - except ValueError: - return (0, 0, 0) - - -def _is_default_value(control, raw_value): - """Compare la valeur reçue du formulaire à la valeur par défaut du - réglage (en tenant compte de l'unité ajoutée pour les sliders), pour - savoir si le champ a simplement été renvoyé tel quel sans être - personnalisé.""" - default = control.get("default", "") - if control["type"] == "slider": - unit = control.get("unit", "") - default = f"{default}{unit}" if default not in (None, "") else "" - return str(raw_value) == str(default) - - -def save_element_controls(slug, element_id, form): - """Lit, pour chaque réglage du widget de cet élément, la valeur envoyée - par le panneau de propriétés (un seul formulaire, un champ par réglage) - et met à jour content / attributs / style en conséquence.""" - el = get_element(slug, element_id) - if not el: - return - meta = widget_meta(el["widget"]) - content = el["content"] - attributes = dict(el["attributes"]) - style = dict(el["style"]) - toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration) - - for control in meta.get("controls", []): - ctype = control["type"] - field_name = "ctrl_" + control["key"] - - if ctype == "toggle_token": - _kind, name = control["target"].split(":", 1) - toggle_groups.setdefault(name, set()) - if form.get(field_name) is not None: - toggle_groups[name].add(control["token"]) - continue - - if ctype == "clause_list": - # Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ - # tableau par colonne ("field_name_champ[]"/"_operateur[]"/ - # "_valeur[]"), reconstituées ici en une liste de clauses par - # position (zip) — même principe que field_name[]/field_type[] - # pour les champs d'un objet (routes/objects/parse_field_rows.py). - champs = form.getlist(field_name + "_champ[]") - operateurs = form.getlist(field_name + "_operateur[]") - valeurs = form.getlist(field_name + "_valeur[]") - clauses = [ - {"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal", - "valeur": valeurs[i] if i < len(valeurs) else ""} - for i, champ in enumerate(champs) - ] - encoded = encode_clauses(clauses) - if encoded: - attributes[control["clauses_attr"]] = encoded - else: - attributes.pop(control["clauses_attr"], None) - combinateur = form.get(field_name + "_combinateur") or "et" - if combinateur == "ou": - attributes[control["combinator_attr"]] = combinateur - else: - attributes.pop(control["combinator_attr"], None) - continue - - if ctype == "preset": - chosen = form.get(field_name, control.get("default")) - opt = next((o for o in control["options"] if o[0] == chosen), None) - if opt: - for k, v in opt[2].items(): - if v in (None, ""): - style.pop(k, None) - else: - style[k] = v - if ( - control["key"] == "visibilite" and chosen == "visible" - and el["widget"] not in LAYOUT_CAPABLE_WIDGETS - ): - # "Visible" ne touche volontairement pas "display" (voir - # visibility_control.py) : sur un widget qui A un - # réglage "Disposition interne" (LAYOUT_CONTROLS, - # traité avant celui-ci sur le MÊME enregistrement, - # voir widget_meta.py), ce contrôle réaffirme lui-même - # un "display" non-"none" (flex/etc), donc "Visible" n'a - # rien à faire. Mais un widget SANS ce contrôle (ex. - # "superposition") n'a PERSONNE d'autre pour l'effacer : - # un "display:none" laissé par un "Masqué" précédent (ou - # par le réglage par défaut à la création, voir - # default_style_for_widget.py) restait donc bloqué - # masqué pour toujours, quel que soit le nombre de fois - # où "Visible" était ensuite choisi. - style.pop("display", None) - continue - - if ctype == "scale": - try: - val = float(form.get(field_name, control.get("default", 1))) - except (TypeError, ValueError): - val = 1.0 - if abs(val - 1) < 1e-6: - style.pop("transform", None) - else: - style["transform"] = f"scale({val})" - continue - - if ctype == "size": - # Un seul champ CSS ("width"/"height") mais deux entrées de - # formulaire (valeur + unité) — voir size_override_controls.py - # pour pourquoi ce n'est PAS deux contrôles slider séparés. - _kind, name = control["target"].split(":", 1) - try: - num = float(form.get(field_name + "_value") or 0) - except (TypeError, ValueError): - num = 0.0 - unit = form.get(field_name + "_unit", "%") - if unit not in ("px", "%"): - unit = "%" - if num <= 0: - style.pop(name, None) - else: - num_str = str(int(num)) if num == int(num) else str(num) - style[name] = f"{num_str}{unit}" - continue - - if ctype == "shadow": - # 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur, - # opacité — voir c_shadow.py) combinées en une seule valeur CSS - # "box-shadow" (couleur+opacité fusionnées en rgba(), un - # seul ne portant pas de canal alpha). - _kind, name = control["target"].split(":", 1) - defaults = control.get("default", {}) - - def _num(suffix, fallback): - try: - return float(form.get(field_name + suffix, fallback)) - except (TypeError, ValueError): - return fallback - - x = _num("_x", defaults.get("x", 0)) - y = _num("_y", defaults.get("y", 4)) - blur = _num("_blur", defaults.get("blur", 12)) - spread = _num("_spread", defaults.get("spread", 0)) - opacity = _num("_opacity", defaults.get("opacity", 0.4)) - color = form.get(field_name + "_color") or defaults.get("color", "#000000") - if x == 0 and y == 0 and blur == 0 and spread == 0: - style.pop(name, None) - else: - r, g, b = _hex_to_rgb(color) - - def _px(n): - return str(int(n)) if n == int(n) else str(n) - - style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})" - continue - - target = control["target"] - if ctype == "checkbox": - checked = form.get(field_name) is not None - raw_value = control["true_value"] if checked else control["false_value"] - else: - raw_value = form.get(field_name, control.get("default", "")) - if ctype == "slider": - unit = control.get("unit", "") - raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else "" - - if target == "content": - content = raw_value - else: - kind, name = target.split(":", 1) - if kind == "class": - # "class:xxx" : un jeton de classe CSS externe (ex. un - # modificateur Bulma) parmi d'autres, chacun mémorisé sous sa - # propre clé "_class_xxx" pour ne pas s'écraser entre eux — - # voir _visible_attrs.py qui les rassemble avec la classe de - # base du widget (fixed_attrs) au moment du rendu. - store, name = attributes, "_class_" + name - else: - store = attributes if kind == "attr" else style - if raw_value in (None, ""): - store.pop(name, None) - elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value): - # Ce réglage n'a jamais été personnalisé (la clé est absente - # du style/des attributs stockés) et la valeur reçue est - # simplement celle par défaut affichée dans le champ (un - # ou un slider a TOUJOURS une valeur à - # l'écran, même quand rien n'a été choisi). Comme le - # panneau de propriétés envoie TOUS les réglages du widget - # en un seul formulaire, sauvegarder n'importe quel autre - # champ (ex. changer la disposition) écrirait sinon cette - # valeur par défaut "en dur" dans l'élément — ce qui faisait - # apparaître par exemple une couleur de fond qui n'avait - # jamais été demandée. On laisse donc ce réglage "non - # défini" tant qu'il n'a pas été explicitement changé. - pass - else: - store[name] = raw_value - - for prop, tokens in toggle_groups.items(): - if tokens: - style[prop] = " ".join(sorted(tokens)) - else: - style.pop(prop, None) - - conn = db.connect(slug) - conn.execute( - "UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?", - (content, json.dumps(attributes), json.dumps(style), element_id), - ) - conn.commit() - conn.close() diff --git a/screens/elements/set_personnage_data.py b/screens/elements/set_personnage_data.py deleted file mode 100644 index 93d61e94..00000000 --- a/screens/elements/set_personnage_data.py +++ /dev/null @@ -1,20 +0,0 @@ -import json - -import db - - -def set_personnage_data(slug, element_id, data): - """Fusionne _personnage_data (voir screens/rendering/personnage_data.py) - dans les attributs déjà stockés de cet élément — remplace la source - (Forge/personnalisée) et son contenu (personnage choisi, ou animations - importées) en un seul appel, jamais les autres attributs de l'élément.""" - conn = db.connect(slug) - row = conn.execute("SELECT attributes FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() - if not row: - conn.close() - return - attributes = json.loads(row["attributes"] or "{}") - attributes["_personnage_data"] = json.dumps(data) - conn.execute("UPDATE _screen_elements SET attributes = ? WHERE id = ?", (json.dumps(attributes), element_id)) - conn.commit() - conn.close() diff --git a/screens/elements/update_element_geometry.py b/screens/elements/update_element_geometry.py deleted file mode 100644 index f0155026..00000000 --- a/screens/elements/update_element_geometry.py +++ /dev/null @@ -1,14 +0,0 @@ -import db - - -def update_element_geometry(slug, element_id, x, y, width, height): - """Utilisé par le glisser-déposer / redimensionnement (et par les champs - Position & taille du panneau de droite) — ne touche qu'à la position et - à la taille, rien d'autre.""" - conn = db.connect(slug) - conn.execute( - "UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?", - (x, y, width, height, element_id), - ) - conn.commit() - conn.close() diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index f776bf3e..2ac133d9 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -11,7 +11,7 @@ def ensure_flow_schema(slug): node_type TEXT NOT NULL, x REAL NOT NULL DEFAULT 40, y REAL NOT NULL DEFAULT 40, - trigger_element_id INTEGER REFERENCES _screen_elements(id), + trigger_element_id INTEGER, trigger_event TEXT, cond_definition_id INTEGER, cond_row_id INTEGER, @@ -21,7 +21,7 @@ def ensure_flow_schema(slug): cond_value TEXT, action_type TEXT, target_screen_id INTEGER REFERENCES _screens(id), - target_element_id INTEGER REFERENCES _screen_elements(id), + target_element_id INTEGER, element_property TEXT, element_value TEXT, target_definition_id INTEGER, diff --git a/screens/labels/element_action_properties.py b/screens/labels/element_action_properties.py index 666236e0..8656110f 100644 --- a/screens/labels/element_action_properties.py +++ b/screens/labels/element_action_properties.py @@ -1,66 +1,10 @@ -# ---------- Actions : modifier un élément ---------- - -ELEMENT_ACTION_PROPERTIES = [ - ("visibilite", "Visibilité"), - # Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un - # créateur de reconstruire l'ancien comportement "texte affiché au - # survol" (et bien d'autres) à la main dans la Logique de la scène — - # poser "Modifier un élément → Contenu" sur un déclencheur "Au survol" - # d'un AUTRE élément. La valeur est un texte littéral (pas encore de - # binding vers un champ d'objet ici — voir applyOpenRowBindings pour le - # mécanisme équivalent, propre à "Ouvrir la ligne cliquée"). - ("contenu", "Contenu — texte affiché"), - ("bg_color", "Couleur de fond"), - ("border_color", "Couleur de bordure"), - ("width", "Largeur en %"), - ("height", "Hauteur en %"), - # 3.3 (Confort) — surbrillance générique dynamique : un liseré qui - # clignote autour de l'élément, pour attirer l'attention du joueur vers - # ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) — - # sans avoir à poser une bordure de couleur togglée à la main. Voir - # ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html. - ("surbrillance", "Surbrillance — liseré clignotant"), - # 3.5 (Confort) — verrouillage d'un élément après décision : grisé + - # ignore les clics, DISTINCT de "invisible" (qui garde sa place mais - # disparaît visuellement) — un bouton "Répondre" qui reste visible mais - # devient inerte une fois le mail traité, par exemple. Voir - # ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans - # play.html (pointer-events:none empêche aussi le déclencheur "Au clic" - # de se redéclencher tant que l'élément est désactivé). - ("desactive", "Désactivé — grisé, ignore les clics"), - # Phase 5 (moteur) — déplacement d'élément : "absolu" fixe la position - # (en % du cadre, comme Largeur/Hauteur ci-dessus — les éléments sont - # déjà positionnés ainsi, voir filters/element_style_filter.py) ; - # "relatif" AJOUTE la valeur à la position actuelle plutôt que de - # l'écraser (voir applyElementProperty() dans static/js/play/actions.js) - # — combiné à un déclencheur clavier (Phase 3), ça permet un - # personnage/objet qui se déplace pas à pas à chaque appui de touche. - ("pos_x", "Position horizontale en % (absolue)"), - ("pos_y", "Position verticale en % (absolue)"), - ("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"), - ("pos_y_relatif", "Déplacer verticalement de... % (relatif)"), - # Phase 7 (moteur) — personnage animé par sprites : quel sens regarde - # l'élément (utile pour un personnage qui se déplace au clavier, voir - # applyElementProperty() dans static/js/play/actions.js — retourne - # l'élément en miroir plutôt que de nécessiter une 2e feuille de - # sprites "vue de dos"). - ("orientation", "Orientation — sens du personnage"), -] -ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES) - -ELEMENT_SURBRILLANCE_VALUES = [ - ("on", "Activer la surbrillance"), - ("off", "Désactiver la surbrillance"), - ("toggle", "Basculer activée / désactivée"), -] -ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES) - -ELEMENT_DESACTIVE_VALUES = [ - ("on", "Désactiver — griser, ignorer les clics"), - ("off", "Réactiver"), - ("toggle", "Basculer désactivé / réactivé"), -] -ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES) +# ---------- Actions : orientation d'un objet de scène (jeu_2d) ---------- +# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde +# l'objet (utile pour un personnage qui se déplace au clavier, voir +# applyElementProperty() dans static/js/play/actions.js — retourne +# l'élément en miroir plutôt que de nécessiter une 2e feuille de sprites +# "vue de dos"). Repris par OBJECT_ACTION_PROPERTIES (screens/scenes/ +# flow_palette.py) et routes/scenes/scene_edit_view.py. ELEMENT_ORIENTATION_VALUES = [ ("droite", "Faire face à droite"), diff --git a/screens/legacy_actions/__init__.py b/screens/legacy_actions/__init__.py deleted file mode 100644 index c718b1f2..00000000 --- a/screens/legacy_actions/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Ancien système d'actions par élément (_actions) — conservé pour ne rien -casser sur les jeux déjà créés, remplacé par le module flow/.""" diff --git a/screens/legacy_actions/add_action.py b/screens/legacy_actions/add_action.py deleted file mode 100644 index dab0e4f3..00000000 --- a/screens/legacy_actions/add_action.py +++ /dev/null @@ -1,25 +0,0 @@ -import db - - -def add_action( - slug, element_id, trigger_event, action_type, target_screen_id=None, - target_element_id=None, element_property=None, element_value=None, - target_definition_id=None, target_row_id=None, target_field=None, - data_operation=None, data_value=None, -): - conn = db.connect(slug) - conn.execute( - """INSERT INTO _actions - (element_id, trigger_event, action_type, target_screen_id, - target_element_id, element_property, element_value, - target_definition_id, target_row_id, target_field, data_operation, data_value) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - element_id, trigger_event, action_type, target_screen_id or None, - target_element_id or None, element_property or None, element_value or None, - target_definition_id or None, target_row_id or None, target_field or None, - data_operation or None, data_value or None, - ), - ) - conn.commit() - conn.close() diff --git a/screens/legacy_actions/delete_action.py b/screens/legacy_actions/delete_action.py deleted file mode 100644 index 39dc250c..00000000 --- a/screens/legacy_actions/delete_action.py +++ /dev/null @@ -1,8 +0,0 @@ -import db - - -def delete_action(slug, action_id): - conn = db.connect(slug) - conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,)) - conn.commit() - conn.close() diff --git a/screens/legacy_actions/get_action.py b/screens/legacy_actions/get_action.py deleted file mode 100644 index acb60484..00000000 --- a/screens/legacy_actions/get_action.py +++ /dev/null @@ -1,8 +0,0 @@ -import db - - -def get_action(slug, action_id): - conn = db.connect(slug) - row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone() - conn.close() - return dict(row) if row else None diff --git a/screens/legacy_actions/list_actions.py b/screens/legacy_actions/list_actions.py deleted file mode 100644 index ab9d8672..00000000 --- a/screens/legacy_actions/list_actions.py +++ /dev/null @@ -1,8 +0,0 @@ -import db - - -def list_actions(slug, element_id): - conn = db.connect(slug) - rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall() - conn.close() - return [dict(r) for r in rows] diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 327d16e9..47e98396 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -1,12 +1,9 @@ import db from ..screens_repo.list_screens import list_screens -from ..elements.list_elements import list_elements from ..flow.list_flow_nodes import list_flow_nodes from ..flow.list_flow_edges import list_flow_edges from ..animations.list_animation_clips import list_animation_clips -from ..element_types.list_element_types import list_element_types -from ..element_types.load_template_tree import _load_template_tree from ..rendering.personnage_data import resolve_personnage_animations from ..rendering.personnage_commands import resolve_personnage_commands from ..rendering.personnage_role import resolve_personnage_role @@ -45,59 +42,43 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): flows = {} animations = {} for s in screens_: - if s["kind"] == "jeu_2d": - objects = list_scene_objects(slug, s["id"]) - for o in objects: - o["rendered_html"] = render_scene_object(o) - # Déplacement/animation automatiques (voir - # static/js/play/personnage-controller.js) : chaque - # personnage porte ses touches et son rôle déjà résolus - # (défauts comblés) pour que le runtime client n'ait - # aucune logique de repli à dupliquer — seul un - # personnage "joueur" est déplacé/suivi par la caméra. - if o["kind"] == "personnage": - o["personnage_commandes"] = resolve_personnage_commands(o) - o["personnage_role"] = resolve_personnage_role(o) - if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"): - # Widgets d'interface (voir "🖥️ Interface", - # screens/rendering/dialogue_box_style.py) : ni - # collision, ni cible/source de règle — juste leur - # style, lu par static/js/play/dialogue-box-controller.js. - o["dialogue_box_style"] = resolve_dialogue_box_style(o) - continue - # Boîte de collision (voir static/js/play/conditions.js:: - # elementsOverlap) : posée sur TOUT objet de scène, pas - # seulement "personnage" — un décor/fond peut aussi servir - # d'obstacle. - o["collision"] = resolve_collision_settings(o) - # Règles "à la collision/dans un périmètre -> action" (voir - # "🧩 Collision", templates/scene_edit.html, et - # static/js/play/collision-rules-controller.js pour leur - # exécution) — jamais sur "fond" (jamais une cible), ni sur - # le personnage "joueur" lui-même (ce sont TOUJOURS les - # AUTRES objets qui réagissent à SA présence, jamais - # l'inverse). - if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"): - o["collision_rules"] = resolve_collision_rules(o) - payload_screens.append({**s, "elements": objects}) - else: - elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id) - payload_screens.append({**s, "elements": elements}) + objects = list_scene_objects(slug, s["id"]) + for o in objects: + o["rendered_html"] = render_scene_object(o) + # Déplacement/animation automatiques (voir + # static/js/play/personnage-controller.js) : chaque + # personnage porte ses touches et son rôle déjà résolus + # (défauts comblés) pour que le runtime client n'ait + # aucune logique de repli à dupliquer — seul un + # personnage "joueur" est déplacé/suivi par la caméra. + if o["kind"] == "personnage": + o["personnage_commandes"] = resolve_personnage_commands(o) + o["personnage_role"] = resolve_personnage_role(o) + if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"): + # Widgets d'interface (voir "🖥️ Interface", + # screens/rendering/dialogue_box_style.py) : ni + # collision, ni cible/source de règle — juste leur + # style, lu par static/js/play/dialogue-box-controller.js. + o["dialogue_box_style"] = resolve_dialogue_box_style(o) + continue + # Boîte de collision (voir static/js/play/conditions.js:: + # elementsOverlap) : posée sur TOUT objet de scène, pas + # seulement "personnage" — un décor/fond peut aussi servir + # d'obstacle. + o["collision"] = resolve_collision_settings(o) + # Règles "à la collision/dans un périmètre -> action" (voir + # "🧩 Collision", templates/scene_edit.html, et + # static/js/play/collision-rules-controller.js pour leur + # exécution) — jamais sur "fond" (jamais une cible), ni sur + # le personnage "joueur" lui-même (ce sont TOUJOURS les + # AUTRES objets qui réagissent à SA présence, jamais + # l'inverse). + if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"): + o["collision_rules"] = resolve_collision_rules(o) + payload_screens.append({**s, "elements": objects}) # Le graphe de logique et les animations sont, eux, lus pour TOUS les - # écrans, écrans-modèles compris (include_templates=True) — pas - # seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable - # (ex. "mail content") peut avoir son propre déclencheur/sa propre - # animation posée dans l'éditeur de SON écran-modèle (voir - # screen_edit.html) : sans ses entrées ici, gameData.flows/ - # gameData.animations n'auraient jamais contenu cet écran-modèle côté - # client, et findTriggerNode()/collectAnimationClips() (templates/ - # play.html) — pourtant déjà écrits pour les chercher — n'auraient - # jamais rien trouvé. On ne les ajoute PAS à payload_screens : un - # écran-modèle ne doit jamais être rendu comme un vrai
(il n'est jamais affiché tel quel, seulement - # rechargé en direct à l'intérieur d'un élément qui l'utilise). - # Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE + # écrans. Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE # affichage de la page de jeu (donc toujours à partir du personnage # Forge ACTUELLEMENT assigné), plutôt que figées dans le nœud de flow/ # le clip de Timeline au moment où le créateur les a configurés : sans @@ -107,24 +88,17 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # voir runActionNode()/applyAnimationClip() qui font cette résolution # à l'exécution à partir de gameData.personnage_animations plutôt que # depuis un data_value/custom_keyframes qui contiendrait des frames - # déjà résolues). include_templates=True : un personnage peut vivre - # dans un écran-modèle d'élément de jeu réutilisable, comme les flows/ - # animations ci-dessous. + # déjà résolues). personnage_animations = {} - for s in list_screens(slug, include_templates=True): + for s in screens_: flows[str(s["id"])] = { "nodes": list_flow_nodes(slug, s["id"]), "edges": list_flow_edges(slug, s["id"]), } animations[str(s["id"])] = list_animation_clips(slug, s["id"]) - if s["kind"] == "jeu_2d": - for o in list_scene_objects(slug, s["id"]): - if o["kind"] == "personnage": - personnage_animations[str(o["id"])] = resolve_personnage_animations(o) - else: - for el in list_elements(slug, s["id"]): - if el.get("widget") == "personnage": - personnage_animations[str(el["id"])] = resolve_personnage_animations(el) + for o in list_scene_objects(slug, s["id"]): + if o["kind"] == "personnage": + personnage_animations[str(o["id"])] = resolve_personnage_animations(o) definitions = db.list_definitions(slug) data = {} @@ -164,30 +138,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): for f in full["fields"] ] - # element_type_id -> id de son écran-modèle : nécessaire côté client (voir - # collectAnimationClips() dans play.html) pour savoir, quand un élément de - # jeu réutilisable (ex. "mail content") est posé sur un écran, quels - # autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent - # aussi être joués à l'affichage de cet écran — sans quoi une animation - # posée directement dans l'éditeur du modèle ne se jouait jamais quand le - # modèle est utilisé ailleurs. - element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)} - - # Arbre complet (éléments + descendants) de l'écran-modèle de chaque - # élément de jeu réutilisable — voir screens/element_types/ - # load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu - # créateur ET jeu publié en ligne). Nécessaire ici pour que le port - # client-side de l'export Web/SCORM (static/js/play/offline/, - # rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de - # Répéteur) puisse le rejouer sans base de données — inoffensif pour - # le runtime en ligne, qui l'ignore. - element_type_templates = {} - for type_id_str, template_screen_id in element_types.items(): - if template_screen_id: - tree = _load_template_tree(slug, int(type_id_str)) - if tree: - element_type_templates[type_id_str] = tree - # Instantané des variables globales, pour qu'un nœud Condition puisse en # tester une côté CLIENT (evaluateConditionClause, templates/play.html) — # {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté @@ -216,8 +166,8 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): return { "screens": payload_screens, "flows": flows, "animations": animations, - "element_types": element_types, "data": data, "fields_meta": fields_meta, + "data": data, "fields_meta": fields_meta, "variables": variables, "personnage_animations": personnage_animations, - "definition_names": definition_names, "element_type_templates": element_type_templates, + "definition_names": definition_names, "quests": quests, } diff --git a/screens/rendering/apply_ctx.py b/screens/rendering/apply_ctx.py deleted file mode 100644 index 428b26a7..00000000 --- a/screens/rendering/apply_ctx.py +++ /dev/null @@ -1,14 +0,0 @@ -def _apply_ctx(text, ctx): - """À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par - la valeur de ce champ pour la ligne en cours — appliqué au contenu ET - aux attributs de l'élément et de tous ses descendants.""" - if not ctx or not text: - return text - for k, v in ctx.items(): - # "_forge_play_mode" (voir render_element_html.py) est un indicateur - # interne posé dans ce même ctx, pas une valeur de champ {{...}} — - # à ignorer ici. - if k == "_forge_play_mode": - continue - text = text.replace("{{" + k + "}}", v) - return text diff --git a/screens/rendering/attr_string.py b/screens/rendering/attr_string.py deleted file mode 100644 index 5ef39d43..00000000 --- a/screens/rendering/attr_string.py +++ /dev/null @@ -1,10 +0,0 @@ -import html as html_lib - - -def _attr_string(attrs, extra_style): - parts = [f'style="{html_lib.escape(extra_style)}"'] - for k, v in attrs.items(): - if v in (None, "", False): - continue - parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"') - return " ".join(parts) diff --git a/screens/rendering/auto_id.py b/screens/rendering/auto_id.py deleted file mode 100644 index 334d2643..00000000 --- a/screens/rendering/auto_id.py +++ /dev/null @@ -1,2 +0,0 @@ -def _auto_id(el): - return f"elt-{el['id']}" diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py deleted file mode 100644 index ddd25f84..00000000 --- a/screens/rendering/filter_repeater_rows.py +++ /dev/null @@ -1,199 +0,0 @@ -import json -import re - -import db - -from ..clause_list_codec import decode_clauses - -# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour -# comparer le champ filtré à une valeur qui change en cours de partie (ex: -# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même -# convention de syntaxe que les {{champ}} déjà utilisés ailleurs (contenu -# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS). -_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$") - -# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/) -# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans -# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend -# toujours un point ("Objet.champ"). Le 2e groupe capture un CHEMIN -# optionnel à l'intérieur de la variable ({{$perso.nom}}, {{$scores[0]}}, -# chaînable : {{$perso.arme.degats}}) — utile seulement pour une variable -# de type "objet"/"tableau" (voir _resolve_variable_path ci-dessous), sans -# effet sur une variable scalaire (chemin vide dans ce cas). -_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}\[\]]+)((?:\.[^.{}\[\]]+|\[\d+\])*)\s*\}\}$") - -# Un segment de chemin est soit ".champ" (clé d'objet) soit "[index]" -# (élément de tableau, 0 = premier) — voir _VAR_REF_PATTERN ci-dessus et le -# contrôle "Chemin dans la variable" de la Condition de visibilité -# (visibility_condition_controls.py). -_PATH_SEGMENT_RE = re.compile(r"\.([^.\[\]]+)|\[(\d+)\]") - - -def _resolve_variable_path(raw_value, path): - """Navigue dans une valeur JSON (chaîne stockée en base, colonne TEXT) - selon un chemin ".champ"/"[index]" chaînable. Ne lève jamais : chemin - vide -> valeur brute inchangée (utile pour une variable scalaire, où le - chemin est toujours vide) ; JSON invalide, clé/index absent ou valeur - intermédiaire pas navigable -> None — même convention que le reste de - ce fichier (objet/variable introuvable -> ne matche rien plutôt que - planter).""" - if not path: - return raw_value - try: - current = json.loads(raw_value) if raw_value else None - except (TypeError, ValueError): - return None - for m in _PATH_SEGMENT_RE.finditer(path): - key, index = m.group(1), m.group(2) - try: - current = current[key] if key is not None else current[int(index)] - except (KeyError, IndexError, TypeError): - return None - return current - - -def _field_column(definition, field_name): - """Nom de la vraie colonne SQL d'un champ — un champ "relation" est - stocké dans une colonne "_id" (voir create_definition.py), jamais - sous son nom "propre" : sans ce cas particulier, filtrer ou comparer sur - un champ relation ne trouvait jamais la bonne colonne et ne matchait - donc jamais rien.""" - col = db.slugify(field_name).replace("-", "_") - field = next((f for f in definition["fields"] if f["name"] == field_name), None) - if field and field["type"] == "relation": - col += "_id" - return col - - -def _resolve_filter_value(slug, raw_value, player_id=db.PLAYER_SHARED): - """Si raw_value est une référence "{{Objet.champ}}", va lire la valeur - ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par - convention, un objet utilisé comme "état de partie" — niveau courant, - outils débloqués... — ne garde qu'une seule ligne, mise à jour en place - par des actions "Modifier une donnée" plutôt que d'en créer une - nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va - lire la valeur ACTUELLE de cette variable globale (voir - db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe). - `player_id` : état par joueur (Phase 1) — quel joueur consulte, pour - qu'un objet/une variable per_player renvoie SA valeur (voir - resolve_player_key.py/resolve_row_player_key.py, appelés en interne par - get_global_variable/list_rows).""" - if not raw_value: - return raw_value - stripped = raw_value.strip() - - var_match = _VAR_REF_PATTERN.match(stripped) - if var_match: - variable = db.get_global_variable(slug, var_match.group(1).strip(), player_id) - if not variable: - return None # variable supprimée depuis : ne matche rien plutôt que planter - return _resolve_variable_path(variable.get("value"), var_match.group(2)) - - m = _REF_PATTERN.match(stripped) - if not m: - return raw_value - object_name, field_name = m.group(1).strip(), m.group(2).strip() - for d in db.list_definitions(slug): - if d["name"] == object_name: - full = db.get_definition(slug, d["id"]) - rows = db.list_rows(slug, full, player_id) - if rows: - return rows[0].get(_field_column(full, field_name)) - return None - return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter - - -def _compare(actual, operator, expected, field_type): - """Équivalent Python de compareValues() dans templates/play.html — les - deux doivent rester alignées si on ajoute un opérateur.""" - if field_type == "booleen": - # "actual" est un int SQLite (0/1) pour un champ d'objet, mais une - # chaîne pour une variable globale (voir db/global_vars/, toujours - # stockée en TEXT) — "0" est une chaîne non vide donc VRAIE en - # Python, d'où ce traitement explicite plutôt qu'un simple bool(...). - if isinstance(actual, str): - truthy = actual.strip().lower() not in ("", "0", "false", "faux", "none") - else: - truthy = bool(actual) - a = 1 if truthy else 0 - # "oui"/"non" est le vocabulaire affiché partout ailleurs dans - # l'app pour un champ booléen (voir data_list.html, "Oui" si vrai - # sinon "Non") — une valeur de comparaison fixe tapée "Oui" doit - # donc être reconnue comme vraie ici aussi, pas seulement "1"/ - # "true"/"vrai" (sans quoi "Oui" retombait silencieusement à faux, - # et un widget conditionné sur "est égal à Oui" ne s'affichait - # jamais quand il aurait dû). - e = 1 if str(expected).strip().lower() in ("1", "true", "vrai", "oui") else 0 - return a != e if operator == "different" else a == e - try: - an, en = float(actual), float(expected) - numeric = True - except (TypeError, ValueError): - numeric = False - if numeric: - if operator == "egal": - return an == en - if operator == "different": - return an != en - if operator == "superieur": - return an > en - if operator == "inferieur": - return an < en - if operator == "superieur_egal": - return an >= en - if operator == "inferieur_egal": - return an <= en - return False - as_, es_ = ("" if actual is None else str(actual)), ("" if expected is None else str(expected)) - if operator == "egal": - return as_ == es_ - if operator == "different": - return as_ != es_ - if operator == "superieur": - return as_ > es_ - if operator == "inferieur": - return as_ < es_ - if operator == "superieur_egal": - return as_ >= es_ - if operator == "inferieur_egal": - return as_ <= es_ - return False - - -def _clause_matches(slug, definition, row, clause, player_id=db.PLAYER_SHARED): - field_name = (clause.get("champ") or "").strip() - if not field_name: - return True - operator = clause.get("operateur") or "egal" - expected = _resolve_filter_value(slug, clause.get("valeur") or "", player_id) - field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte") - col = _field_column(definition, field_name) - return _compare(row.get(col), operator, expected, field_type) - - -def filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id=db.PLAYER_SHARED): - """Ne garde, parmi les lignes, que celles qui satisfont la liste de - clauses données (un nombre quelconque), combinées entre elles par ET - (toutes vraies) ou par OU (au moins une vraie) selon `combinator` - ("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses - reconstruites depuis _filtre_champ/_filtre2_champ, voir - filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/ - Titre (clauses illimitées, voir resolve_bound_row.py).""" - if not clauses: - return rows - if combinator == "ou": - return [r for r in rows if any(_clause_matches(slug, definition, r, c, player_id) for c in clauses)] - return [r for r in rows if all(_clause_matches(slug, definition, r, c, player_id) for c in clauses)] - - -def filter_repeater_rows(slug, definition, rows, attrs, player_id=db.PLAYER_SHARED): - """Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent - au(x) filtre(s) optionnel(s) réglé(s) dans ses propriétés (_filtre_champ/ - _filtre_operateur/_filtre_valeur, ET, si renseigné, _filtre2_champ/ - _filtre2_operateur/_filtre2_valeur combiné en ET avec le premier) — voir - 1.2 dans la doc de cadrage Forge Engine ("affichage piloté par la donnée - sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher, - par exemple, que le niveau du parcours en cours (2 conditions : le bon - parcours ET le bon numéro de niveau) plutôt que tous les niveaux.""" - clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre") - return filter_rows_by_clauses(slug, definition, rows, clauses, combinator, player_id) diff --git a/screens/rendering/list_used_forge_characters.py b/screens/rendering/list_used_forge_characters.py index 0b4394ab..004c3150 100644 --- a/screens/rendering/list_used_forge_characters.py +++ b/screens/rendering/list_used_forge_characters.py @@ -1,25 +1,17 @@ from ..screens_repo.list_screens import list_screens -from ..elements.list_elements import list_elements from ..scenes.list_scene_objects import list_scene_objects from .personnage_data import resolve_personnage_data def list_used_forge_characters(slug): """Ensemble des slugs SPRITE_LIBRARY (screens.SPRITE_LIBRARY) - réellement référencés par CE jeu — tous écrans, écrans-modèles compris - (même patron que full_game_payload.py : un personnage peut vivre dans - un écran-modèle d'élément réutilisable). Utilisé par + réellement référencés par CE jeu — tous écrans confondus. Utilisé par publish/build_scorm_package.py pour ne copier dans le .zip exporté que les dossiers static/characters/animals/// réellement utilisés par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque (voir le plan "Bibliothèque de sprites animaux CraftPix").""" used = set() for s in list_screens(slug, include_templates=True): - for el in list_elements(slug, s["id"]): - if el.get("widget") == "personnage": - data = resolve_personnage_data(el) - if data.get("source") == "forge" and data.get("forge_character"): - used.add(data["forge_character"]) for o in list_scene_objects(slug, s["id"]): if o.get("kind") == "personnage": data = resolve_personnage_data(o) diff --git a/screens/rendering/parent_flex_direction.py b/screens/rendering/parent_flex_direction.py deleted file mode 100644 index c5ac0967..00000000 --- a/screens/rendering/parent_flex_direction.py +++ /dev/null @@ -1,9 +0,0 @@ -def _parent_flex_direction(parent_el): - """Sens de la disposition interne d'un conteneur/répéteur/groupe de - champs (voir le réglage "Disposition interne"), ou None s'il est en - empilement libre (pas de display:flex) — utilisé pour adapter la taille - par défaut de ses enfants (voir _style_string).""" - style = (parent_el or {}).get("style") or {} - if style.get("display") != "flex": - return None - return style.get("flex-direction") or "row" diff --git a/screens/rendering/render_checkbox_or_radio.py b/screens/rendering/render_checkbox_or_radio.py deleted file mode 100644 index e97eddd8..00000000 --- a/screens/rendering/render_checkbox_or_radio.py +++ /dev/null @@ -1,19 +0,0 @@ -import html as html_lib - -from .visible_attrs import _visible_attrs -from .apply_ctx import _apply_ctx -from .style_string import _style_string -from .attr_string import _attr_string - - -def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None): - input_type = "checkbox" if el.get("widget") == "case_a_cocher" else "radio" - attrs = _visible_attrs(el, meta, ctx) - attrs["type"] = input_type - style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction) - content = _apply_ctx(el.get("content") or "", ctx) - #