From d5a59bdbd885b193add40b48ede216dd437413a3 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 06:21:13 +0200 Subject: [PATCH 01/22] =?UTF-8?q?Fusion=20des=20deux=20moteurs=20:=20type?= =?UTF-8?q?=20d'=C3=A9cran=20par=20=C3=A9cran,=20plus=20par=20projet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "document" (écrans %) et "jeu_2d" (scène pixels) devient une propriété PAR ÉCRAN (_screens.kind, migration automatique idempotente dans ensure_schema.py, source = l'ancien game_type au niveau projet) plutôt qu'un choix figé pour tout le jeu — un même projet peut désormais mélanger écrans classiques et scènes 2D librement. - routes/screens/screen_edit.py : dispatch vers l'éditeur de scène selon screen["kind"] (l'écran demandé), plus game["game_type"]. - screens/payload/full_game_payload.py, templates/play.html, static/js/play/screens.js : le rendu jouable (payload, markup, bascule du mode plein-écran #playFrame) décide écran par écran, y compris en cours de partie (changer d'écran ne recharge pas la page). - screens/screens_repo/create_screen.py : nouveau paramètre kind. Co-Authored-By: Claude Sonnet 5 --- routes/scenes/scene_edit_view.py | 13 +++--- routes/screens/screen_edit.py | 16 ++++--- screens/payload/full_game_payload.py | 14 +++--- screens/screens_repo/create_screen.py | 6 +-- screens/screens_repo/ensure_schema.py | 9 ++++ static/js/play/screens.js | 14 ++++++ templates/game_dashboard.html | 8 ++++ templates/play.html | 37 ++++++++-------- tests/test_animal_sprite_library.py | 11 +++-- tests/test_game_type.py | 64 ++++++++++++++++----------- tests/test_scene_edit_view.py | 12 ++--- tests/test_scene_objects.py | 8 ++-- 12 files changed, 132 insertions(+), 80 deletions(-) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 43d7c93b..d9ae09cb 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -8,12 +8,13 @@ import screens def render_scene_edit(slug, screen_id, game): """Vue de l'éditeur de scène 2D — appelée par routes/screens/ - screen_edit.py (le dispatcher, selon game["game_type"]), PAS une route + screen_edit.py (le dispatcher, selon screen["kind"]), PAS une route Flask elle-même : même URL/endpoint "screen_edit" pour les deux types - de jeu. Réutilise TEL QUEL tout ce qui est déjà générique côté logique - de flow/animation/variables/événements (voir le plan "Fondations - d'une plateforme multi-éditeurs") — seuls les objets de scène et la - palette de nœuds proposée diffèrent de l'éditeur document.""" + d'écran. Réutilise TEL QUEL tout ce qui est déjà générique côté logique + de flow/animation/variables/événements (voir screens/screens_repo/ + ensure_schema.py — le type d'écran est une propriété PAR ÉCRAN) — + seuls les objets de scène et la palette de nœuds proposée diffèrent de + l'éditeur document.""" screen = screens.get_screen(slug, screen_id) if not screen: abort(404) @@ -68,6 +69,8 @@ def render_scene_edit(slug, screen_id, game): element_action_property_labels_json=json.dumps(dict(screens.OBJECT_ACTION_PROPERTIES)), condition_operators=screens.CONDITION_OPERATORS, 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), data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS), condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS), action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS_2D), diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 0701db0d..6e6de085 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -12,15 +12,17 @@ from ..scenes.scene_edit_view import render_scene_edit @app.route("/game//screens//edit") def screen_edit(slug, screen_id): game = db.game_meta(slug) - if game["game_type"] == "jeu_2d": - # Fondations multi-éditeurs (voir db/games/get_game_type.py) : - # même route/endpoint pour les deux types de jeu, dispatch ici — - # tout le reste de cette fonction ne concerne QUE l'éditeur - # document ("document", le défaut, comportement inchangé). - return render_scene_edit(slug, screen_id, game) 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) @@ -106,6 +108,8 @@ def screen_edit(slug, screen_id): 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, diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 6175f705..678d17af 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -27,18 +27,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): conception), l'identifiant réel du visiteur pour une partie publique (voir core/player_identity.py) — chaque objet/variable per_player renvoie alors la valeur propre à CE joueur.""" - # Fondations multi-éditeurs — un jeu "jeu_2d" (voir db/games/ - # get_game_type.py) construit "elements" depuis _scene_objects - # (objets de scène en pixels) plutôt que _screen_elements (DOM en %) ; + # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un + # écran "jeu_2d" construit "elements" depuis _scene_objects (objets de + # scène en pixels) plutôt que _screen_elements (DOM en %) — décidé + # ÉCRAN PAR ÉCRAN (s["kind"]), un même jeu peut mélanger les deux ; # tout le reste de cette fonction (flows/animations/variables/data) - # est déjà générique et inchangé pour les deux types de jeu. - is_scene_game = db.get_game_type(slug) == "jeu_2d" + # est déjà générique et inchangé pour les deux types d'écran. screens_ = list_screens(slug) payload_screens = [] flows = {} animations = {} for s in screens_: - if is_scene_game: + if s["kind"] == "jeu_2d": objects = list_scene_objects(slug, s["id"]) for o in objects: o["rendered_html"] = render_scene_object(o) @@ -80,7 +80,7 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): "edges": list_flow_edges(slug, s["id"]), } animations[str(s["id"])] = list_animation_clips(slug, s["id"]) - if is_scene_game: + 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) diff --git a/screens/screens_repo/create_screen.py b/screens/screens_repo/create_screen.py index 12ca8317..35685675 100644 --- a/screens/screens_repo/create_screen.py +++ b/screens/screens_repo/create_screen.py @@ -3,7 +3,7 @@ import db from .ensure_schema import ensure_schema -def create_screen(slug, name, is_template=False): +def create_screen(slug, name, is_template=False, kind="document"): ensure_schema(slug) conn = db.connect(slug) if is_template: @@ -12,8 +12,8 @@ def create_screen(slug, name, is_template=False): max_order = conn.execute("SELECT MAX(order_index) AS m FROM _screens WHERE is_template = 0").fetchone()["m"] order_index = (max_order or 0) + 1 conn.execute( - "INSERT INTO _screens (name, order_index, is_template) VALUES (?, ?, ?)", - (name, order_index, 1 if is_template else 0), + "INSERT INTO _screens (name, order_index, is_template, kind) VALUES (?, ?, ?, ?)", + (name, order_index, 1 if is_template else 0, kind), ) screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] conn.commit() diff --git a/screens/screens_repo/ensure_schema.py b/screens/screens_repo/ensure_schema.py index 92190d60..fe699c88 100644 --- a/screens/screens_repo/ensure_schema.py +++ b/screens/screens_repo/ensure_schema.py @@ -123,6 +123,15 @@ def ensure_schema(slug): # (jamais lu par son rendu) — voir screens/scenes/. conn.execute("ALTER TABLE _screens ADD COLUMN scene_width INTEGER NOT NULL DEFAULT 960") conn.execute("ALTER TABLE _screens ADD COLUMN scene_height INTEGER NOT NULL DEFAULT 540") + if "kind" not in screen_cols: + # Fusion des moteurs — "document" (écran %) ou "jeu_2d" (scène + # pixels) devient une propriété PAR ÉCRAN plutôt que par projet + # (voir db/games/get_game_type.py, désormais lu UNIQUEMENT ici, + # pour la rétro-compatibilité) : un même jeu peut mélanger les + # deux types d'écran librement (voir routes/screens/screen_edit.py, + # qui dispatche maintenant sur screen["kind"]). + conn.execute("ALTER TABLE _screens ADD COLUMN kind TEXT NOT NULL DEFAULT 'document'") + conn.execute("UPDATE _screens SET kind = ?", (db.get_game_type(slug),)) action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()} for col_name, col_def in [ ("target_element_id", "INTEGER REFERENCES _screen_elements(id)"), diff --git a/static/js/play/screens.js b/static/js/play/screens.js index 2f6af425..4f1d0220 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -37,6 +37,7 @@ function showScreen(id) { el.style.display = 'block'; window.currentScreenId = id; applyScreenAspect(el.dataset.aspect || '9/16'); + applyScreenKind(el.dataset.kind || 'document'); } // Phase 7 — nettoie les animations de sprites de l'écran quitté (sinon // une boucle setInterval reste active sur un élément désormais caché, @@ -83,6 +84,19 @@ function applyScreenAspect(aspect) { } } +// Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : le +// type d'écran est désormais choisi PAR ÉCRAN — changer d'écran ne +// recharge pas la page, donc #playFrame doit pouvoir basculer entre sa +// taille fixe en pixels (scène "jeu_2d", voir .sceneMode dans play.html) +// et sa taille en ratio fluide (écran "document") À CHAQUE showScreen(), +// pas une seule fois au chargement. +function applyScreenKind(kind) { + var frame = document.getElementById('playFrame'); + if (frame) { + frame.classList.toggle('sceneMode', kind === 'jeu_2d'); + } +} + function screenIndexById(id) { return screensData.findIndex(s => s.id === id); } diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index b1f0ed36..0134861e 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -34,6 +34,13 @@ {% if screen_list %} @@ -45,6 +52,7 @@ {{ loop.index }} + {{ '🧩' if s.kind == 'jeu_2d' else '📄' }} {{ s.name }} {% if loop.first %} ★ Départ{% endif %} diff --git a/templates/play.html b/templates/play.html index c4c7913d..2c13250f 100644 --- a/templates/play.html +++ b/templates/play.html @@ -27,18 +27,19 @@ } #playFrame{ position:relative; overflow:hidden; background:#0b0d12; - {% if game.game_type == 'jeu_2d' %} - /* Fondations multi-éditeurs — une scène jeu_2d a une taille FIXE en - pixels (voir scene_width/scene_height, ensure_schema.py), pas un - ratio fluide : #playFrame remplit simplement tout le viewport, la - scène elle-même se centre dedans (voir .playScreen ci-dessous et - la boucle d'écrans jeu_2d plus bas). */ - width:100vw; height:100vh; - {% else %} width: min(100vw, calc(100vh * var(--ratio-w, 9) / var(--ratio-h, 16))); height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9))); - {% endif %} } + /* Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : le + type d'écran ("document"/"jeu_2d") est désormais choisi PAR ÉCRAN, pas + figé pour toute la page au chargement — changer d'écran ne recharge + pas la page (voir showScreen() dans static/js/play/screens.js), donc + la taille de #playFrame doit pouvoir basculer EN COURS DE PARTIE. + .sceneMode (posée/enlevée par showScreen() selon screen.kind) donne à + une scène jeu_2d sa taille FIXE en pixels (scene_width/scene_height) — + elle-même se centre dedans (voir .playScreen.playScene ci-dessous) — + au lieu du ratio fluide par défaut ci-dessus. */ + #playFrame.sceneMode{ width:100vw; height:100vh; } .playScreen{ position:absolute; inset:0; display:none; } .playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; } .playElement{ @@ -70,27 +71,25 @@
-{% if game.game_type == 'jeu_2d' %} - -{% for s in screens_data %} -
+ au %/DOM d'un écran "document") : juste le HTML tel quel. --> +
{% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %}
-{% endfor %} {% else %} -{% for s in screens_data %} -
+
{% for el in s.elements if not el.parent_id %}
{{ el.rendered_html|safe }}
{% endfor %}
-{% endfor %} {% endif %} +{% endfor %} {% if not screens_data %}
Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.
{% endif %} @@ -118,6 +117,8 @@ runtimePayload: {{ runtime_payload_url|tojson }}, flowNodeRunData: {{ flow_node_run_data_url|tojson }}, flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }}, + flowNodeRunScore: {{ flow_node_run_score_url|tojson }}, + flowNodeRunStatus: {{ flow_node_run_status_url|tojson }}, flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }}, }; diff --git a/tests/test_animal_sprite_library.py b/tests/test_animal_sprite_library.py index 4b990283..b9defa57 100644 --- a/tests/test_animal_sprite_library.py +++ b/tests/test_animal_sprite_library.py @@ -46,7 +46,10 @@ def test_document_editor_shows_animal_characters_to_admin(game, client): def _create_jeu2d_game_as(post_client, name, slug_owner_client=None): - resp = post_client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False) + # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un + # jeu n'a plus de type propre — seul l'écran créé ensuite par chaque + # test (screens.create_screen(..., kind="jeu_2d")) est une scène. + resp = post_client.post("/games/new", data={"name": name}, follow_redirects=False) assert resp.status_code == 302 return resp.headers["Location"].rstrip("/").split("/")[-1] @@ -56,7 +59,7 @@ def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_c with user_client.session_transaction() as sess: import auth auth.set_project_slug(sess["user_id"], slug) - screen_id = screens.create_screen(slug, "Scène 1") + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) assert "animal-bunny-01" not in html @@ -66,7 +69,7 @@ def test_scene_object_add_rejects_animal_character_for_a_user_account(client, us with user_client.session_transaction() as sess: import auth auth.set_project_slug(sess["user_id"], slug) - screen_id = screens.create_screen(slug, "Scène 1") + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") resp = user_client.post( f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage", "forge_character": "animal-bunny-01"}, @@ -76,7 +79,7 @@ def test_scene_object_add_rejects_animal_character_for_a_user_account(client, us def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup): slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin")) - screen_id = screens.create_screen(slug, "Scène 1") + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") resp = client.post( f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage", "forge_character": "animal-bunny-01"}, diff --git a/tests/test_game_type.py b/tests/test_game_type.py index 97839409..e28393d4 100644 --- a/tests/test_game_type.py +++ b/tests/test_game_type.py @@ -1,38 +1,48 @@ -"""Type de jeu (fondations multi-éditeurs) : "document" (éditeur générique -actuel) ou "jeu_2d" (futur éditeur de scène) — choisi à la création, -jamais modifié ensuite. Réutilise _meta comme is_public_played, aucune -migration pour les jeux déjà créés (retombent sur "document").""" +"""Type d'écran (fusion des moteurs, voir screens/screens_repo/ +ensure_schema.py) : "document" (éditeur générique, éléments en %) ou +"jeu_2d" (éditeur de scène, objets en pixels) — choisi PAR ÉCRAN, à sa +création, plus par jeu. Un jeu peut librement mélanger les deux.""" import db +import screens -def test_default_game_type_is_document(client, game): - assert db.get_game_type(game) == "document" - assert db.game_meta(game)["game_type"] == "document" +def test_default_screen_kind_is_document(client, game): + screen_id = screens.create_screen(game, "Accueil") + assert screens.get_screen(game, screen_id)["kind"] == "document" -def test_create_game_with_jeu_2d_type(tmp_game_slug_cleanup): - slug = tmp_game_slug_cleanup(db.create_game("pytest_jeu2d_test", game_type="jeu_2d")) - assert db.get_game_type(slug) == "jeu_2d" - assert db.game_meta(slug)["game_type"] == "jeu_2d" +def test_create_screen_with_jeu_2d_kind(tmp_game_slug_cleanup): + slug = tmp_game_slug_cleanup(db.create_game("pytest_jeu2d_test")) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + screen = screens.get_screen(slug, screen_id) + assert screen["kind"] == "jeu_2d" -def test_games_new_route_creates_jeu_2d_game(client): - resp = client.post("/games/new", data={"name": "pytest_route_jeu2d", "game_type": "jeu_2d"}, follow_redirects=False) +def test_a_game_can_mix_both_screen_kinds(tmp_game_slug_cleanup): + slug = tmp_game_slug_cleanup(db.create_game("pytest_mixed_test")) + doc_id = screens.create_screen(slug, "Accueil", kind="document") + scene_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + assert screens.get_screen(slug, doc_id)["kind"] == "document" + assert screens.get_screen(slug, scene_id)["kind"] == "jeu_2d" + + +def test_screens_new_route_creates_jeu_2d_screen(client, game): + resp = client.post( + f"/game/{game}/screens/new", + data={"name": "pytest_route_jeu2d", "kind": "jeu_2d"}, + follow_redirects=False, + ) assert resp.status_code == 302 - slug = resp.headers["Location"].rstrip("/").split("/")[-1] - try: - assert db.get_game_type(slug) == "jeu_2d" - finally: - import shutil - shutil.rmtree(db.game_dir(slug), ignore_errors=True) + screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2]) + assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d" -def test_games_new_route_rejects_unknown_game_type(client): - resp = client.post("/games/new", data={"name": "pytest_route_bad_type", "game_type": "n_importe_quoi"}, follow_redirects=False) +def test_screens_new_route_rejects_unknown_kind(client, game): + resp = client.post( + f"/game/{game}/screens/new", + data={"name": "pytest_route_bad_kind", "kind": "n_importe_quoi"}, + follow_redirects=False, + ) assert resp.status_code == 302 - slug = resp.headers["Location"].rstrip("/").split("/")[-1] - try: - assert db.get_game_type(slug) == "document" - finally: - import shutil - shutil.rmtree(db.game_dir(slug), ignore_errors=True) + screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2]) + assert screens.get_screen(game, screen_id)["kind"] == "document" diff --git a/tests/test_scene_edit_view.py b/tests/test_scene_edit_view.py index 5a28fbbf..8f7931f9 100644 --- a/tests/test_scene_edit_view.py +++ b/tests/test_scene_edit_view.py @@ -1,8 +1,8 @@ """Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) — -appelée par routes/screens/screen_edit.py selon game_type, RÉUTILISE tel -quel le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/ -animation-timeline.js) — voir le plan "Fondations d'une plateforme -multi-éditeurs".""" +appelée par routes/screens/screen_edit.py selon screen["kind"] (fusion des +moteurs, voir screens/screens_repo/ensure_schema.py), RÉUTILISE tel quel +le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/ +animation-timeline.js).""" import json import re @@ -12,9 +12,9 @@ from conftest import screen_edit_js_bundle def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"): - resp = client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False) + resp = client.post("/games/new", data={"name": name}, follow_redirects=False) slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) - screen_id = screens.create_screen(slug, "Scène 1") + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") return slug, screen_id diff --git a/tests/test_scene_objects.py b/tests/test_scene_objects.py index 688be390..c91bc805 100644 --- a/tests/test_scene_objects.py +++ b/tests/test_scene_objects.py @@ -1,6 +1,6 @@ """Objets de scène (_scene_objects) — fondations de l'éditeur de scène 2D -(jeux "jeu_2d", voir db/games/get_game_type.py). Positions en PIXELS -fixes (pas des %, voir ensure_scene_schema.py). kind="personnage" +(écrans "jeu_2d", voir screens/screens_repo/ensure_schema.py). Positions +en PIXELS fixes (pas des %, voir ensure_scene_schema.py). kind="personnage" réutilise TELLE QUELLE la structure _personnage_data et les fonctions resolve_personnage_* déjà écrites pour le widget "personnage" de l'éditeur document (Phase 8) — même bibliothèque Forge, même moteur @@ -10,8 +10,8 @@ import screens def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_scene_test"): - slug = tmp_game_slug_cleanup(db.create_game(name, game_type="jeu_2d")) - screen_id = screens.create_screen(slug, "Scène 1") + slug = tmp_game_slug_cleanup(db.create_game(name)) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") return slug, screen_id From 0f230248820913daa99e0c7312d919b7b7c4eddb Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 06:22:03 +0200 Subject: [PATCH 02/22] =?UTF-8?q?Structure=20de=20dossiers=20par=20utilisa?= =?UTF-8?q?teur=20:=20projects///?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le rangement plat projects// par une vraie arborescence par compte créateur, sans toucher aux routes existantes (aucune ne déclare de "/" dans son : le chemin composé "propriétaire_projet" reste un détail interne à db/, décodé par db/games/project_slug.py, seul fichier à modifier si la convention change un jour). - db/game_dir.py, create_game.py, delete_game.py, list_games.py, move_game.py : résolvent/construisent ce chemin composé. Bénéfice direct : deux comptes différents peuvent chacun avoir un projet nommé pareil sans collision (l'unicité ne se vérifie plus que par dossier propriétaire). move_game.py renomme désormais le dossier PROPRIÉTAIRE entier (changement d'email), prêt pour un futur multi-projet. - scripts/migrate_flat_project_slugs.py : migration des projets déjà créés sous l'ancien rangement plat (simulation par défaut, --apply pour exécuter). - routes/auth/profile.py, tests/conftest.py : suppression de compte/jeu de test via db.delete_game (nettoie aussi le dossier propriétaire devenu vide) plutôt qu'un shutil.rmtree direct du seul dossier projet. Co-Authored-By: Claude Sonnet 5 --- db/game_dir.py | 10 +++- db/games/create_game.py | 46 +++++++++------ db/games/delete_game.py | 13 +++++ db/games/list_games.py | 33 ++++++++--- db/games/move_game.py | 48 ++++++++++----- db/games/project_slug.py | 32 ++++++++++ routes/auth/profile.py | 10 ++-- routes/games/games_new.py | 14 +++-- scripts/migrate_flat_project_slugs.py | 84 +++++++++++++++++++++++++++ tests/conftest.py | 10 ++-- 10 files changed, 245 insertions(+), 55 deletions(-) create mode 100644 db/games/project_slug.py create mode 100644 scripts/migrate_flat_project_slugs.py diff --git a/db/game_dir.py b/db/game_dir.py index bfc8d731..921a1bc3 100644 --- a/db/game_dir.py +++ b/db/game_dir.py @@ -1,7 +1,15 @@ import os from .constants import PROJECTS_DIR +from .games.project_slug import split_slug def game_dir(slug): - return os.path.join(PROJECTS_DIR, slug) + # Structure de dossiers par utilisateur (voir db/games/project_slug.py) + # : un slug composé "propriétaire_projet" résout vers un vrai chemin + # imbriqué projects/// — repli sur l'ancien + # comportement plat pour un slug pas encore migré (project_part=None). + owner_folder, project_part = split_slug(slug) + if project_part is None: + return os.path.join(PROJECTS_DIR, slug) + return os.path.join(PROJECTS_DIR, owner_folder, project_part) diff --git a/db/games/create_game.py b/db/games/create_game.py index 0500c7fe..9dd29eeb 100644 --- a/db/games/create_game.py +++ b/db/games/create_game.py @@ -3,30 +3,43 @@ import os from ..connection import connect from ..game_dir import game_dir from ..slugify import slugify -from .get_game_type import DEFAULT_GAME_TYPE +from .project_slug import build_slug -def create_game(name, slug_override=None, game_type=DEFAULT_GAME_TYPE): +def create_game(name, owner_folder=None, project_slug_override=None): """Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js reliés entre eux, et sa base de données dédiée (nom du jeu en méta). - slug_override : impose le nom du dossier au lieu de le dériver de - `name` — utilisé par l'inscription (auth/) pour que le dossier d'un - compte porte son adresse email (slugifiée), indépendamment du nom - donné à son jeu. Toujours rendu unique de la même façon (suffixe - -2/-3...) si, par un hasard extrême, le dossier existe déjà. + owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py — + structure de dossiers par utilisateur), typiquement + slugify(email_du_compte) — le slug final composé + "_" résout vers un vrai chemin imbriqué + projects///. Omis (None) : repli sur + l'ancien comportement plat (slug à un seul segment, projects//) + — utile pour les tests de la couche données qui n'ont pas de compte + utilisateur à représenter. - game_type : "document" (défaut, éditeur générique actuel) ou "jeu_2d" - (éditeur de scène — voir get_game_type.py) — choisi une fois pour - toutes à la création, gate quel éditeur/quel rendu jouable ce jeu - utilise (voir routes/screens/screen_edit.py).""" - slug = slugify(slug_override) if slug_override else slugify(name) - base = slug + project_slug_override : impose le nom du dossier PROJET au lieu de le + dériver de `name` — utilisé par l'inscription (auth/, voir + routes/onboarding/) pour que le tout premier projet d'un compte porte + un nom de dossier prévisible. Le suffixe d'unicité (-2/-3...) ne + compare que parmi les projets déjà présents dans CE `owner_folder` + (deux comptes différents peuvent donc avoir chacun un projet nommé + pareil, sans collision). + + Un projet n'a plus de "type" propre (voir screens/screens_repo/ + ensure_schema.py — fusion des moteurs, "document"/"jeu_2d" est + désormais une propriété PAR ÉCRAN) : un projet fraîchement créé n'a + par défaut aucun écran, c'est à l'appelant d'en créer un premier avec + le "kind" voulu (voir screens.create_screen).""" + project_part = slugify(project_slug_override) if project_slug_override else slugify(name) + base = project_part n = 2 - while os.path.isdir(game_dir(base)): - base = f"{slug}-{n}" + while os.path.isdir(game_dir(build_slug(owner_folder, base) if owner_folder else base)): + base = f"{project_part}-{n}" n += 1 - slug = base + project_part = base + slug = build_slug(owner_folder, project_part) if owner_folder else project_part os.makedirs(game_dir(slug), exist_ok=True) @@ -84,7 +97,6 @@ console.log("Jeu chargé."); """ ) conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,)) - conn.execute("INSERT INTO _meta (key, value) VALUES ('game_type', ?)", (game_type,)) conn.commit() conn.close() return slug diff --git a/db/games/delete_game.py b/db/games/delete_game.py index 48be17e9..afa8bf4c 100644 --- a/db/games/delete_game.py +++ b/db/games/delete_game.py @@ -1,7 +1,20 @@ +import os import shutil +from ..constants import PROJECTS_DIR from ..game_dir import game_dir +from .project_slug import split_slug def delete_game(slug): shutil.rmtree(game_dir(slug)) + # Structure de dossiers par utilisateur (voir project_slug.py) : + # nettoie aussi le dossier propriétaire s'il ne contient plus aucun + # projet — purement cosmétique (game_dir()/list_games() fonctionnent + # très bien avec un dossier propriétaire vide), évite juste + # d'accumuler des dossiers fantômes sous projects/. + owner_folder, project_part = split_slug(slug) + if project_part is not None: + owner_path = os.path.join(PROJECTS_DIR, owner_folder) + if os.path.isdir(owner_path) and not os.listdir(owner_path): + os.rmdir(owner_path) diff --git a/db/games/list_games.py b/db/games/list_games.py index 656bf76d..19b34ec6 100644 --- a/db/games/list_games.py +++ b/db/games/list_games.py @@ -3,18 +3,35 @@ import os from ..constants import PROJECTS_DIR from ..connection import connect from ..db_path import db_path +from .project_slug import build_slug def list_games(): - """Scanne le dossier projects/ : chaque sous-dossier avec un game.db est un jeu.""" + """Scanne projects/ : structure par utilisateur (voir + db/games/project_slug.py) — projects///game.db, + deux niveaux — plus le repli sur l'ancien rangement plat + projects//game.db pour tout slug pas encore migré (voir + scripts/migrate_flat_project_slugs.py).""" games = [] if not os.path.isdir(PROJECTS_DIR): return games - for slug in sorted(os.listdir(PROJECTS_DIR)): - path = db_path(slug) - if os.path.isfile(path): - conn = connect(slug) - row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() - conn.close() - games.append({"slug": slug, "name": row["value"] if row else slug}) + for entry in sorted(os.listdir(PROJECTS_DIR)): + entry_path = os.path.join(PROJECTS_DIR, entry) + if not os.path.isdir(entry_path): + continue + if os.path.isfile(os.path.join(entry_path, "game.db")): + # Repli : un ancien dossier plat porte directement game.db. + _append_game(games, entry) + continue + for project_part in sorted(os.listdir(entry_path)): + slug = build_slug(entry, project_part) + if os.path.isfile(db_path(slug)): + _append_game(games, slug) return games + + +def _append_game(games, slug): + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() + conn.close() + games.append({"slug": slug, "name": row["value"] if row else slug}) diff --git a/db/games/move_game.py b/db/games/move_game.py index 387ba126..a8ecd869 100644 --- a/db/games/move_game.py +++ b/db/games/move_game.py @@ -1,23 +1,43 @@ import os +from ..constants import PROJECTS_DIR from ..game_dir import game_dir +from .project_slug import build_slug, split_slug -def move_game(old_slug, new_slug): - """Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un +def move_game(old_slug, new_owner_folder): + """Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un utilisateur change son adresse email (voir routes/auth/profile.py, - project_slug = slugify(email) pour un compte "user") : le dossier - physique du jeu doit suivre. Rend le nouveau slug unique de la même - façon que create_game() si, par un hasard extrême, il correspond déjà - à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé), - à enregistrer comme nouveau project_slug.""" - if old_slug == new_slug: + owner_folder = slugify(email) pour un compte "user", voir + db/games/project_slug.py) : le dossier physique doit suivre. Renomme + le dossier propriétaire ENTIER en un coup (déplace tous les projets de + ce compte ensemble — prêt pour un futur multi-projet, même si un seul + aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire + unique de la même façon que create_game() si, par un hasard extrême, + il correspond déjà à un dossier existant. Renvoie le nouveau slug + composé FINAL (même projet, propriétaire renommé), à enregistrer comme + nouveau project_slug.""" + old_owner_folder, project_part = split_slug(old_slug) + if project_part is None: + # Slug pas encore migré vers la structure par utilisateur (voir + # scripts/migrate_flat_project_slugs.py) — repli sur l'ancien + # comportement plat (renomme le dossier du projet lui-même). + if old_slug == new_owner_folder: + return old_slug + base = new_owner_folder + n = 2 + while os.path.isdir(game_dir(base)): + base = f"{new_owner_folder}-{n}" + n += 1 + os.rename(game_dir(old_slug), game_dir(base)) + return base + + if old_owner_folder == new_owner_folder: return old_slug - base = new_slug + base = new_owner_folder n = 2 - while os.path.isdir(game_dir(base)): - base = f"{new_slug}-{n}" + while os.path.isdir(os.path.join(PROJECTS_DIR, base)): + base = f"{new_owner_folder}-{n}" n += 1 - new_slug = base - os.rename(game_dir(old_slug), game_dir(new_slug)) - return new_slug + os.rename(os.path.join(PROJECTS_DIR, old_owner_folder), os.path.join(PROJECTS_DIR, base)) + return build_slug(base, project_part) diff --git a/db/games/project_slug.py b/db/games/project_slug.py new file mode 100644 index 00000000..be9a8043 --- /dev/null +++ b/db/games/project_slug.py @@ -0,0 +1,32 @@ +"""Convention du slug composé "propriétaire_projet" (voir le plan +"Structure de dossiers propriétaire/projet") : un slug encode en réalité +deux segments — le dossier PROPRIÉTAIRE (slugifié depuis l'email du +compte qui a créé le projet) et le dossier PROJET à l'intérieur — pour +que projects/ soit une vraie arborescence par utilisateur +(projects///), sans jamais toucher aux ~40 fichiers +de routes qui déclarent dans leur URL : le slug reste, pour elles, +un seul segment plat et opaque. + +"_" comme séparateur est sûr et non ambigu : slugify() (voir +db/slugify.py) ne produit jamais de "_", seulement [a-z0-9-]+ — un slug +composé peut donc toujours être découpé sans collision sur la PREMIÈRE +occurrence de "_" (le dossier propriétaire, lui, n'en contient jamais). + +Seule une poignée de fonctions dans db/games/ (game_dir, create_game, +move_game, list_games) savent que le slug encode ce chemin composé — si +la convention change un jour, ce fichier est le seul à modifier.""" + + +def build_slug(owner_folder, project_part): + return f"{owner_folder}_{project_part}" + + +def split_slug(slug): + """(owner_folder, project_part) si `slug` est bien composé, sinon + (slug, None) — un slug "plat" (créé avant cette convention, pas encore + migré par scripts/migrate_flat_project_slugs.py) reste lisible tel + quel par le code appelant (repli sur l'ancien comportement).""" + owner_folder, sep, project_part = slug.partition("_") + if not sep: + return slug, None + return owner_folder, project_part diff --git a/routes/auth/profile.py b/routes/auth/profile.py index 73726aa2..3bced677 100644 --- a/routes/auth/profile.py +++ b/routes/auth/profile.py @@ -1,5 +1,4 @@ import os -import shutil from flask import g, redirect, render_template, request, session, url_for @@ -106,9 +105,12 @@ def profile_delete(): # plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses # projets restent intacts. if user["role"] != "admin" and user.get("project_slug"): - game_dir = db.game_dir(user["project_slug"]) - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + if os.path.isdir(db.game_dir(user["project_slug"])): + # db.delete_game (voir db/games/project_slug.py — structure de + # dossiers par utilisateur) nettoie aussi le dossier + # propriétaire s'il devient vide, contrairement à un simple + # shutil.rmtree du dossier projet seul. + db.delete_game(user["project_slug"]) auth.delete_user(user["id"]) session.clear() diff --git a/routes/games/games_new.py b/routes/games/games_new.py index a45fa912..72d354b6 100644 --- a/routes/games/games_new.py +++ b/routes/games/games_new.py @@ -1,4 +1,4 @@ -from flask import request, redirect, url_for +from flask import g, request, redirect, url_for import db @@ -10,8 +10,12 @@ def games_new(): name = request.form.get("name", "").strip() if not name: return redirect(url_for("index")) - game_type = request.form.get("game_type", db.DEFAULT_GAME_TYPE) - if game_type not in ("document", "jeu_2d"): - game_type = db.DEFAULT_GAME_TYPE - slug = db.create_game(name, game_type=game_type) + # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un + # jeu n'a plus de type propre — aucun écran créé d'office ici, comme + # avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en + # choisissant "document" ou "jeu_2d" pour chacun). + # owner_folder (voir db/games/project_slug.py — structure de dossiers + # par utilisateur) : posé par core/auth_guard.py pour tout compte + # connecté, y compris l'admin qui utilise cette route. + slug = db.create_game(name, owner_folder=db.slugify(g.current_user["email"])) return redirect(url_for("game_dashboard", slug=slug)) diff --git a/scripts/migrate_flat_project_slugs.py b/scripts/migrate_flat_project_slugs.py new file mode 100644 index 00000000..36496bd1 --- /dev/null +++ b/scripts/migrate_flat_project_slugs.py @@ -0,0 +1,84 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : migre les +projets encore rangés à plat (projects//game.db, l'ancien schéma) +vers la structure par utilisateur (projects///, +voir db/games/project_slug.py et le plan "Structure de dossiers +propriétaire/projet") — db/game_dir.py sait déjà lire les deux formes, +cette migration n'est donc pas bloquante pour faire tourner le moteur, +mais range enfin les dossiers comme voulu et met à jour project_slug (voir +auth/set_project_slug.py) pour les comptes concernés. + +owner_folder = l'ancien slug plat lui-même : c'était déjà, historiquement, +slugify(email) pour le SEUL projet d'un compte "user" (voir l'ancienne +version de routes/auth/register_2fa.py, avant l'onboarding guidé) — donc +le bon dossier propriétaire, tel quel. project_part = slugify(nom du jeu +stocké en _meta), pour donner un vrai nom de dossier PROJET distinct. + +Mode simulation par défaut (n'écrit rien, affiche ce qui serait fait) — +`--apply` requis pour exécuter réellement : + python scripts/migrate_flat_project_slugs.py # aperçu + python scripts/migrate_flat_project_slugs.py --apply # exécute +""" +import os +import sys + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BASE_DIR) + +import db # noqa: E402 +import auth # noqa: E402 +from db.constants import PROJECTS_DIR # noqa: E402 +from db.games.project_slug import build_slug # noqa: E402 +from auth.connection import connect as connect_auth_db # noqa: E402 + + +def _flat_project_slugs(): + """Slugs plats trouvés directement sous projects/ (un dossier avec + game.db à la racine, pas dans un sous-dossier propriétaire).""" + if not os.path.isdir(PROJECTS_DIR): + return [] + found = [] + for entry in sorted(os.listdir(PROJECTS_DIR)): + entry_path = os.path.join(PROJECTS_DIR, entry) + if os.path.isfile(os.path.join(entry_path, "game.db")): + found.append(entry) + return found + + +def migrate(apply=False): + slugs = _flat_project_slugs() + if not slugs: + print("Aucun projet à plat trouvé — rien à migrer.") + return + conn = connect_auth_db() + for old_slug in slugs: + game_meta = db.game_meta(old_slug) + project_part = db.slugify(game_meta["name"]) or "jeu" + new_slug = build_slug(old_slug, project_part) + owning_users = [ + dict(r) for r in + conn.execute("SELECT id, email FROM _users WHERE project_slug = ?", (old_slug,)).fetchall() + ] + print(f"{old_slug!r} -> {new_slug!r}" + f" (comptes concernés : {[u['email'] for u in owning_users] or 'aucun'})") + if not apply: + continue + old_dir = os.path.join(PROJECTS_DIR, old_slug) + new_dir = os.path.join(PROJECTS_DIR, old_slug, project_part) + # Renommer EN PLACE (dossier propriétaire = ancien dossier projet, + # déplacé dans son propre sous-dossier) demanderait un dossier + # intermédiaire — plus sûr de passer par un nom temporaire. + tmp_dir = old_dir + ".migrating" + os.rename(old_dir, tmp_dir) + os.makedirs(os.path.dirname(new_dir), exist_ok=True) + os.rename(tmp_dir, new_dir) + for user in owning_users: + auth.set_project_slug(user["id"], new_slug) + conn.close() + if apply: + print(f"{len(slugs)} projet(s) migré(s).") + else: + print("Aperçu seulement — relance avec --apply pour exécuter.") + + +if __name__ == "__main__": + migrate(apply="--apply" in sys.argv) diff --git a/tests/conftest.py b/tests/conftest.py index e1a83063..66e79b1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,9 +109,8 @@ def user_game(client, user_client): with user_client.session_transaction() as sess: auth.set_project_slug(sess["user_id"], slug) yield slug - game_dir = db.game_dir(slug) - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + if os.path.isdir(db.game_dir(slug)): + db.delete_game(slug) @pytest.fixture @@ -122,9 +121,8 @@ def game(client): assert resp.status_code == 302 slug = resp.headers["Location"].rstrip("/").split("/")[-1] yield slug - game_dir = db.game_dir(slug) - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + if os.path.isdir(db.game_dir(slug)): + db.delete_game(slug) _STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js") From 76a50fa87a53adcb6d6b2eff6c2def17e9283a50 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 06:22:45 +0200 Subject: [PATCH 03/22] =?UTF-8?q?Onboarding=20guid=C3=A9=20syst=C3=A9matiq?= =?UTF-8?q?ue=20+=20tableau=20de=20bord=20simplifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comportement SYSTÉMATIQUE à chaque création de jeu (admin compris), pas une formalité réservée à l'inscription : /onboarding (routes/onboarding/) devient le point d'entrée unique — 4 cartes retournables (survol = explication au dos), défilement horizontal animé vers le nom du jeu. Un admin y repasse à volonté (pas de project_slug dédié, jamais bloqué/ redirigé vers un projet précédent) ; un compte "user" n'en a plus qu'un créé d'office (routes/auth/register_2fa.py), guidé ici à la place. - db/games/game_type_catalog.py : catalogue des 4 types (Quiz/ Embranchement-escape game/RPG/Créer mon jeu de A à Z), _meta['onboarding_type'] décide du "kind" du premier écran créé et si le tableau de bord complet reste accessible. - routes/games/game_dashboard.py, templates/game_dashboard_simple.html : un type restreint (quiz/embranchement/rpg) voit désormais SON tableau de bord (même route que "custom"), rendu en version simplifiée — juste ses écrans en cartes avec un aperçu RÉEL du contenu (scène mise à l'échelle par container query CSS, adaptée à la largeur réelle de la carte). "+ Ajouter un écran" n'y propose pas de choix de type : imposé par le projet (routes/screens/screens_new.py), verrouillé aussi côté serveur. - core/auth_guard.py : plus de blocage de game_dashboard par type — la restriction se fait au rendu, pas à l'accès à la route. Co-Authored-By: Claude Sonnet 5 --- core/auth_guard.py | 24 ++++ db/__init__.py | 14 +- db/games/game_meta.py | 13 +- db/games/game_type_catalog.py | 98 +++++++++++++ routes/__init__.py | 2 +- routes/auth/login_2fa.py | 5 + routes/auth/register_2fa.py | 20 ++- routes/games/game_dashboard.py | 36 ++++- routes/onboarding/__init__.py | 1 + routes/onboarding/onboarding_new.py | 66 +++++++++ routes/screens/screens_new.py | 15 +- scripts/backfill_onboarding_type.py | 41 ++++++ static/js/game_dashboard_simple.js | 15 ++ static/js/onboarding/onboarding_new.js | 65 +++++++++ static/style.css | 118 ++++++++++++++++ templates/base.html | 6 +- templates/game_dashboard_simple.html | 76 ++++++++++ templates/index.html | 29 ++-- templates/onboarding/onboarding_new.html | 46 ++++++ tests/test_auth.py | 54 +++++-- tests/test_onboarding.py | 171 +++++++++++++++++++++++ tests/test_profile.py | 10 +- tests/test_publish.py | 3 +- 23 files changed, 876 insertions(+), 52 deletions(-) create mode 100644 db/games/game_type_catalog.py create mode 100644 routes/onboarding/__init__.py create mode 100644 routes/onboarding/onboarding_new.py create mode 100644 scripts/backfill_onboarding_type.py create mode 100644 static/js/game_dashboard_simple.js create mode 100644 static/js/onboarding/onboarding_new.js create mode 100644 templates/game_dashboard_simple.html create mode 100644 templates/onboarding/onboarding_new.html create mode 100644 tests/test_onboarding.py diff --git a/core/auth_guard.py b/core/auth_guard.py index 5f60bbd7..3a455682 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -31,9 +31,20 @@ _PUBLIC_ENDPOINTS = { "forgot_password", "reset_password", "game_play_public", "runtime_payload_public", "flow_node_run_data_public", "flow_node_run_variable_public", + "flow_node_run_score_public", "flow_node_run_status_public", "flow_node_run_add_row_public", } +# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion +# requise, mais indépendants d'avoir un projet ou non (voir onboarding +# guidé ci-dessous) : un compte pas encore passé par /onboarding doit +# quand même pouvoir changer son mot de passe, son email, ou supprimer +# son compte. +_REACHABLE_WITHOUT_PROJECT = { + "onboarding_new", "profile", "profile_update_name", "profile_update_email", + "profile_regenerate_recovery_codes", "profile_change_password", "profile_delete", +} + @app.before_request def _require_login_and_enforce_project_isolation(): @@ -61,9 +72,22 @@ def _require_login_and_enforce_project_isolation(): if user["role"] != "admin": if endpoint in ("index", "games_new") and user.get("project_slug"): return redirect(url_for("game_dashboard", slug=user["project_slug"])) + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un + # compte "user" fraîchement inscrit n'a plus de projet créé + # d'office (voir routes/auth/register_2fa.py) — tant qu'il n'en a + # pas, seules ces routes restent atteignables (toutes exigent déjà + # une connexion, absentes de _PUBLIC_ENDPOINTS). + if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT: + return redirect(url_for("onboarding_new")) slug = request.view_args.get("slug") if request.view_args else None if slug is not None and slug != user.get("project_slug"): abort(403) + # Type d'onboarding "restreint" (quiz/embranchement/rpg, voir + # db/games/game_type_catalog.py) : game_dashboard reste atteignable + # comme pour "custom", mais routes/games/game_dashboard.py y rend + # une version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/ + # Éléments de jeu/Variables) — rien à bloquer ici, la restriction + # se fait au niveau du rendu, pas de l'accès à la route. if endpoint == "game_delete": # Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en # recréer un une fois supprimé (games_new le renvoie toujours diff --git a/db/__init__.py b/db/__init__.py index a6a19fc2..e409d25e 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un vrai INSERT dans cette table. """ -from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES +from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS from .slugify import slugify from .table_name_for import table_name_for from .game_dir import game_dir @@ -36,6 +36,11 @@ from .games.create_game import create_game from .games.update_game_name import update_game_name from .games.delete_game import delete_game from .games.move_game import move_game +from .games.game_type_catalog import ( + ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, + get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, + is_restricted, +) from .definitions.list_definitions import list_definitions from .definitions.get_definition import get_definition @@ -66,6 +71,10 @@ from .global_vars.update_global_variable import update_global_variable from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id +from .scoring.get_score import get_score +from .scoring.set_score_value import set_score_value +from .scoring.set_status import set_status + from .custom_events.list_custom_events import list_custom_events from .custom_events.get_custom_event import get_custom_event from .custom_events.create_custom_event import create_custom_event @@ -73,9 +82,12 @@ from .custom_events.update_custom_event import update_custom_event __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", + "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", "slugify", "table_name_for", "game_dir", "db_path", "connect", "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", "is_public_played", "set_public_played", "get_game_type", "DEFAULT_GAME_TYPE", + "ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", + "get_onboarding_type_raw", "set_onboarding_type", "is_restricted", "list_definitions", "get_definition", "create_definition", "rename_definition", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "delete_definition", diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 38043ae6..40eee84b 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -1,6 +1,6 @@ from ..connection import connect from .is_public_played import is_public_played -from .get_game_type import get_game_type +from .game_type_catalog import get_onboarding_type def game_meta(slug): @@ -14,8 +14,11 @@ def game_meta(slug): # ligne" en a besoin pour afficher son état actuel), sans avoir à # appeler is_public_played(slug) séparément dans chaque route. "is_public_played": is_public_played(slug), - # Type de jeu (fondations multi-éditeurs) : "document" (éditeur - # générique) ou "jeu_2d" (éditeur de scène) — voir - # get_game_type.py et routes/screens/screen_edit.py. - "game_type": get_game_type(slug), + # Onboarding guidé (voir game_type_catalog.py) : utilisé par + # templates/base.html pour cacher le lien "Tableau de bord" à un + # compte "restreint" (quiz/embranchement/rpg). + "onboarding_type": get_onboarding_type(slug), + # Pas de "game_type" ici : depuis la fusion des moteurs (voir + # screens/screens_repo/ensure_schema.py), "document"/"jeu_2d" est + # une propriété PAR ÉCRAN (screen["kind"]), plus du projet. } diff --git a/db/games/game_type_catalog.py b/db/games/game_type_catalog.py new file mode 100644 index 00000000..438a9dd7 --- /dev/null +++ b/db/games/game_type_catalog.py @@ -0,0 +1,98 @@ +"""Onboarding guidé (voir routes/onboarding/onboarding_new.py) : les 4 +choix proposés à CHAQUE création de projet — PAS un 3e type de moteur +(voir screens/screens_repo/ensure_schema.py, fusion des moteurs : +"document"/"jeu_2d" est une propriété PAR ÉCRAN), une classification +produit posée par-dessus. Chaque choix décide du "kind" du tout premier +écran créé (screen_kind ci-dessous, aussi imposé à tout écran ajouté +ensuite — voir routes/screens/screens_new.py) et si le tableau de bord +complet (game_dashboard, objets/éléments de jeu/variables) reste +accessible ensuite, ou seulement sa version simplifiée (juste les écrans +en cartes — voir routes/games/game_dashboard.py, restricted). + +_meta['onboarding_type'] (une ligne _meta par projet, même convention que +is_public_played/l'ancien game_type) retient CE choix — jamais lu par le +rendu jouable, seulement par le routage (routes/games/game_dashboard.py, +routes/screens/screens_new.py).""" +import db + +DEFAULT_ONBOARDING_TYPE = "custom" + +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", + "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.", + "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, + }, +} + + +def get_onboarding_type_raw(slug): + """None si _meta['onboarding_type'] est absent — distingue un projet + JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par + l'admin via "+ Nouveau jeu") d'un projet explicitement "custom".""" + conn = db.connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'onboarding_type'").fetchone() + conn.close() + return row["value"] if row else None + + +def get_onboarding_type(slug): + return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE + + +def set_onboarding_type(slug, onboarding_type): + conn = db.connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)", + (onboarding_type,), + ) + conn.commit() + conn.close() + + +def is_restricted(onboarding_type_or_slug): + """Accepte directement une clé de ONBOARDING_TYPES, ou un slug de + projet (résout alors son onboarding_type d'abord) — pratique aussi + bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a + déjà la clé).""" + onboarding_type = ( + onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES + else get_onboarding_type(onboarding_type_or_slug) + ) + return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"] + + diff --git a/routes/__init__.py b/routes/__init__.py index 54de4fd4..004bb4e4 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, public_play, scenes +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes, onboarding diff --git a/routes/auth/login_2fa.py b/routes/auth/login_2fa.py index c5006a9b..f5373f6a 100644 --- a/routes/auth/login_2fa.py +++ b/routes/auth/login_2fa.py @@ -52,5 +52,10 @@ def login_2fa(): return redirect(next_url) if user["role"] == "admin": return redirect(url_for("index")) + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : + # un compte "user" peut ne pas encore avoir de projet du tout + # (inscription interrompue avant l'onboarding). + if not user.get("project_slug"): + return redirect(url_for("onboarding_new")) return redirect(url_for("game_dashboard", slug=user["project_slug"])) return render_template("auth/login_2fa.html", error=error) diff --git a/routes/auth/register_2fa.py b/routes/auth/register_2fa.py index 9c2d32ef..ddb05db4 100644 --- a/routes/auth/register_2fa.py +++ b/routes/auth/register_2fa.py @@ -1,7 +1,6 @@ from flask import redirect, render_template, request, session, url_for import auth -import db from core.flask_app import app @@ -28,16 +27,6 @@ def register_2fa(): else: auth.reset_failed_attempts(user_id) auth.confirm_totp(user_id) - # Un compte "user" (pas "admin") reçoit ICI son unique projet, - # nommé d'après son adresse email (voir create_user.py — le - # rôle est déjà tranché à la création : premier compte jamais - # créé = admin, tous les suivants = user) — un admin, lui, - # reste libre de créer ses jeux comme avant ("+ Nouveau jeu"), - # aucun projet ne lui est imposé d'office. - if user["role"] != "admin": - slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"]) - auth.set_project_slug(user_id, slug) - user = auth.get_user_by_id(user_id) session.pop("pending_register_user_id", None) session["user_id"] = user_id # Générés ici, une seule fois, à l'instant même où la 2FA @@ -50,7 +39,14 @@ def register_2fa(): session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id) if user["role"] == "admin": return redirect(url_for("index")) - return redirect(url_for("game_dashboard", slug=user["project_slug"])) + # Un compte "user" (pas "admin") n'a plus de projet créé + # d'office ici — voir routes/onboarding/onboarding_new.py : il + # choisit d'abord un type de jeu et nomme son projet, PUIS + # auth.set_project_slug() y est appelé (core/auth_guard.py + # renvoie ici tout compte sans project_slug). Un admin, lui, + # reste libre de créer ses jeux comme avant ("+ Nouveau jeu"), + # aucun projet ne lui est imposé d'office. + return redirect(url_for("onboarding_new")) qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"]) return render_template( diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index bbb71a0e..a1c10fed 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -13,8 +13,42 @@ def game_dashboard(slug): 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.""" + 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.""" game = db.game_meta(slug) + if db.is_restricted(slug): + return _render_simple_dashboard(slug, game) + return _render_full_dashboard(slug, game) + + +def _render_simple_dashboard(slug, game): + screen_list = screens.list_screens(slug) + previews = {} + for s in screen_list: + if s["kind"] == "jeu_2d": + 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: + previews[s["id"]] = { + "kind": "document", + "elements": screens.list_elements(slug, s["id"]), + } + 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 diff --git a/routes/onboarding/__init__.py b/routes/onboarding/__init__.py new file mode 100644 index 00000000..3a6b157f --- /dev/null +++ b/routes/onboarding/__init__.py @@ -0,0 +1 @@ +from . import onboarding_new diff --git a/routes/onboarding/onboarding_new.py b/routes/onboarding/onboarding_new.py new file mode 100644 index 00000000..d0f04212 --- /dev/null +++ b/routes/onboarding/onboarding_new.py @@ -0,0 +1,66 @@ +from flask import g, redirect, render_template, request, url_for + +import auth +import db +import screens + +from core.flask_app import app + + +@app.route("/onboarding", methods=["GET", "POST"]) +def onboarding_new(): + """Point d'entrée UNIQUE pour créer un jeu — comportement SYSTÉMATIQUE + à chaque création, pas une formalité réservée à la toute première + inscription : chaque projet (celui d'un compte "user" comme n'importe + lequel des projets d'un admin) choisit son propre type ici. Prépare + aussi le futur multi-projet payant (plusieurs jeux de types + différents pour un même compte "user"). + + Un compte "user" fraîchement inscrit n'a plus de projet créé d'office + (voir routes/auth/register_2fa.py) : core/auth_guard.py le redirige + ici tant qu'il n'a pas encore de project_slug — et n'en aura jamais + qu'UN SEUL (voir core/auth_guard.py). Un admin, lui, n'a PAS de + project_slug dédié (illimité, voir create_user.py) : repasse ici à + volonté pour créer un projet de PLUS, jamais redirigé ailleurs. + + Choisit un type (voir db.ONBOARDING_TYPES) puis nomme son jeu — crée + le projet ET son premier écran (du "kind" correspondant), puis envoie + vers son tableau de bord (routes/games/game_dashboard.py y rend une + version SIMPLIFIÉE pour un type restreint — quiz/embranchement/rpg — + ou la version complète pour "custom", même route pour tous).""" + user = g.current_user + if user["role"] != "admin" and user.get("project_slug"): + # Retour en arrière après coup (l'onboarding a déjà été fait) — + # ne recrée rien, renvoie directement vers son tableau de bord. Ne + # s'applique PAS à un admin (illimité, jamais "déjà fait" une fois + # pour toutes). + return redirect(url_for("game_dashboard", slug=user["project_slug"])) + + error = None + if request.method == "POST": + onboarding_type = request.form.get("onboarding_type", "") + name = request.form.get("name", "").strip() + if onboarding_type not in db.ONBOARDING_TYPES: + error = "Choisis un type de jeu." + elif not name: + error = "Donne un nom à ton jeu." + else: + return redirect(_create_project_for_user(user, onboarding_type, name)) + + return render_template( + "onboarding/onboarding_new.html", + onboarding_types=db.ONBOARDING_TYPES, error=error, + ) + + +def _create_project_for_user(user, onboarding_type, name): + slug = db.create_game(name, owner_folder=db.slugify(user["email"])) + db.set_onboarding_type(slug, onboarding_type) + if user["role"] != "admin": + # project_slug = LE seul projet d'un compte "user" (voir + # core/auth_guard.py) — un admin n'utilise pas cette colonne, ses + # projets se retrouvent via db.list_games() comme avant. + auth.set_project_slug(user["id"], slug) + screen_kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"] + screens.create_screen(slug, name, kind=screen_kind) + return url_for("game_dashboard", slug=slug) diff --git a/routes/screens/screens_new.py b/routes/screens/screens_new.py index 7566471f..cab0bdc1 100644 --- a/routes/screens/screens_new.py +++ b/routes/screens/screens_new.py @@ -1,5 +1,6 @@ from flask import request, redirect, url_for +import db import screens from core.flask_app import app @@ -8,7 +9,19 @@ 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" if name: - screen_id = screens.create_screen(slug, name) + screen_id = screens.create_screen(slug, name, kind=kind) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) return redirect(url_for("game_dashboard", slug=slug, tab="screens")) diff --git a/scripts/backfill_onboarding_type.py b/scripts/backfill_onboarding_type.py new file mode 100644 index 00000000..00b29fe6 --- /dev/null +++ b/scripts/backfill_onboarding_type.py @@ -0,0 +1,41 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : pose la clé +_meta['onboarding_type'] sur tout projet existant qui ne l'a pas encore +(créé avant l'existence de l'onboarding guidé — voir routes/onboarding/ +onboarding_new.py et db/games/game_type_catalog.py). Pas strictement +nécessaire : get_onboarding_type() retombe déjà sur DEFAULT_ONBOARDING_TYPE +("custom") pour tout projet sans cette clé — ce script rend juste ce choix +EXPLICITE dans les données plutôt qu'implicite, sans rien changer au +comportement observable. + +Marque "custom" tout projet trouvé sans onboarding_type (comportement +neutre : tableau de bord complet garanti visible, comme aujourd'hui) — +aucune distinction "jeu_2d"/RPG à faire ici, cette notion n'existe plus au +niveau projet depuis la fusion des moteurs (écran par écran, voir +screens/screens_repo/ensure_schema.py) : un projet historiquement "jeu_2d" +reste "custom" comme n'importe quel autre, ses écrans gardent +individuellement leur kind="jeu_2d". + + python scripts/backfill_onboarding_type.py +""" +import os +import sys + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BASE_DIR) + +import db # noqa: E402 + + +def backfill(): + games = db.list_games() + updated = 0 + for g in games: + if db.get_onboarding_type_raw(g["slug"]) is None: + db.set_onboarding_type(g["slug"], "custom") + updated += 1 + print(f"{g['slug']!r} -> 'custom'") + print(f"{updated}/{len(games)} projet(s) mis à jour.") + + +if __name__ == "__main__": + backfill() diff --git a/static/js/game_dashboard_simple.js b/static/js/game_dashboard_simple.js new file mode 100644 index 00000000..a053b6a8 --- /dev/null +++ b/static/js/game_dashboard_simple.js @@ -0,0 +1,15 @@ +// Dashboard simplifié (templates/game_dashboard_simple.html, voir +// routes/games/game_dashboard.py) — affiche/masque le petit formulaire +// "+ Ajouter un écran", même comportement que toggleDashCreate() du +// tableau de bord complet (static/js/screen_edit/tabs-and-blocks.js), +// repris ici pour ne pas dépendre de tout ce fichier (pensé pour l'éditeur +// complet, pas cette page). +function toggleDashCreate(tab) { + var bar = document.getElementById("dashCreate-" + tab); + if (!bar) return; + bar.style.display = bar.style.display === "none" ? "flex" : "none"; + if (bar.style.display === "flex") { + var firstInput = bar.querySelector("input, select"); + if (firstInput) firstInput.focus(); + } +} diff --git a/static/js/onboarding/onboarding_new.js b/static/js/onboarding/onboarding_new.js new file mode 100644 index 00000000..38b31703 --- /dev/null +++ b/static/js/onboarding/onboarding_new.js @@ -0,0 +1,65 @@ +// Onboarding guidé (templates/onboarding/onboarding_new.html) — interactions +// pures DOM, aucune logique métier ici (voir routes/onboarding/ +// onboarding_new.py pour la validation réelle, côté serveur). Deux +// "pages" (choix du type / nom du jeu) posées côte à côte dans +// #onboardingTrack, révélées par un défilement horizontal animé en CSS +// (voir .onboardingTrack--step2 dans static/style.css) plutôt qu'un vrai +// changement de page — pas de rechargement, pas d'aller-retour serveur +// tant que le formulaire n'est pas soumis pour de vrai. + +function forgeOnboardingGoToStep(step) { + var track = document.getElementById("onboardingTrack"); + if (track) track.classList.toggle("onboardingTrack--step2", step === 2); + if (step === 2) { + var nameField = document.getElementById("onboardingNameInput"); + // Après la fin de l'animation de défilement (voir la durée de + // transition posée sur .onboardingTrack) — focus immédiat plus tôt + // ferait sauter le scroll du champ à moitié visible. + if (nameField) setTimeout(function () { nameField.focus(); }, 500); + } +} + +function forgeOnboardingChooseType(card) { + document.querySelectorAll(".onboardingCard").forEach(function (c) { + c.classList.toggle("is-selected", c === card); + }); + var hiddenType = document.getElementById("onboardingTypeHidden"); + if (hiddenType) hiddenType.value = card.dataset.type || ""; + forgeOnboardingUpdateSubmitState(); + forgeOnboardingGoToStep(2); +} + +function forgeOnboardingUpdateSubmitState() { + var nameField = document.getElementById("onboardingNameInput"); + var hiddenType = document.getElementById("onboardingTypeHidden"); + var submitBtn = document.getElementById("onboardingSubmitBtn"); + if (!nameField || !hiddenType || !submitBtn) return; + submitBtn.disabled = !(hiddenType.value && nameField.value.trim()); +} + +function forgeOnboardingInit() { + // Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé + // par un +{% endblock %} diff --git a/tests/test_auth.py b/tests/test_auth.py index b25fadb9..93909773 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -34,19 +34,41 @@ def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!" def _confirm_2fa(client): """Récupère la clé TOTP affichée sur la page de confirmation et soumet le code du moment — comme un humain qui vient de scanner le - QR code avec son application.""" + QR code avec son application. Un compte "user" n'a plus de projet + créé d'office ici (voir routes/onboarding/onboarding_new.py) — la + redirection renvoie désormais vers /onboarding, pas /game/ ; + voir _complete_onboarding ci-dessous pour les tests qui ont besoin + d'un vrai projet ensuite.""" html = client.get("/register/2fa").get_data(as_text=True) secret = re.search(r"([A-Z0-9]+)", html).group(1) code = pyotp.TOTP(secret).now() return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret +def _complete_onboarding(client, name="Mon jeu", onboarding_type="custom"): + """Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py) + — la plupart des tests de ce fichier n'ont pas besoin de tester + l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un + project_slug réel pour la suite ; "custom" est le choix le plus proche + du comportement historique (tableau de bord complet).""" + return client.post( + "/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False + ) + + def _cleanup_project(email): - game_dir = db.game_dir(db.slugify(email)) import os - import shutil - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + user = auth.get_user_by_email(email) + if user and user.get("project_slug"): + if os.path.isdir(db.game_dir(user["project_slug"])): + # db.delete_game (voir db/games/project_slug.py — structure de + # dossiers par utilisateur) nettoie aussi le dossier + # propriétaire s'il devient vide, contrairement à un simple + # shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires + # vides s'accumulent sous projects/ d'un run de tests à l'autre + # (déjà observé : ça a fini par provoquer une collision de nom + # dans db.move_game, un dossier vide comptant comme "occupé"). + db.delete_game(user["project_slug"]) def test_anonymous_request_redirects_to_login(anon_client): @@ -100,13 +122,23 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie user = auth.get_user_by_email("flow@example.com") assert user["totp_confirmed"] == 1 assert user["role"] == "user" # jamais le premier compte de la session de tests - assert user["project_slug"] == db.slugify("flow@example.com") + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus + # de projet créé d'office ici — connecté mais sans project_slug tant + # qu'il n'a pas choisi un type de jeu. + assert user["project_slug"] is None try: - assert resp.headers["Location"] == "/game/" + user["project_slug"] - # Connecté pour de vrai maintenant. + assert resp.headers["Location"] == "/onboarding" + # Connecté pour de vrai maintenant, mais renvoyé vers l'onboarding + # tant qu'aucun projet n'existe (voir core/auth_guard.py). resp2 = anon_client.get("/", follow_redirects=False) assert resp2.status_code == 302 - assert resp2.headers["Location"] == "/game/" + user["project_slug"] + assert resp2.headers["Location"] == "/onboarding" + + onboard_resp = _complete_onboarding(anon_client) + assert onboard_resp.status_code == 302 + user = auth.get_user_by_email("flow@example.com") + assert user["project_slug"] is not None + assert onboard_resp.headers["Location"] == "/game/" + user["project_slug"] finally: _cleanup_project("flow@example.com") @@ -123,6 +155,7 @@ def test_wrong_totp_code_is_rejected(anon_client): def test_login_requires_correct_password_then_totp(anon_client): _register(anon_client, "login@example.com") resp, secret = _confirm_2fa(anon_client) + _complete_onboarding(anon_client) slug = auth.get_user_by_email("login@example.com")["project_slug"] try: anon_client.post("/logout") @@ -146,6 +179,7 @@ def test_login_requires_correct_password_then_totp(anon_client): def test_non_admin_user_is_isolated_to_their_own_project(anon_client): _register(anon_client, "isolated@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("isolated@example.com") try: # Un autre projet (celui de l'admin partagé par conftest.py, ou @@ -173,6 +207,7 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client): retour — bloqué plutôt que risqué.""" _register(anon_client, "nodelete@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("nodelete@example.com") try: resp = anon_client.post(f"/game/{user['project_slug']}/delete") @@ -281,6 +316,7 @@ def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client): def test_login_with_a_recovery_code_instead_of_totp(anon_client): _register(anon_client, "recoverylogin@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("recoverylogin@example.com") slug = user["project_slug"] codes = auth.generate_recovery_codes(user["id"]) diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 00000000..2daeb0c4 --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,171 @@ +"""Onboarding guidé (routes/onboarding/onboarding_new.py) : un compte +"user" fraîchement inscrit choisit un type de jeu (Quiz/Embranchement/ +RPG/Créer mon jeu de A à Z) avant d'avoir accès à quoi que ce soit — voir +le plan "Fusion des deux moteurs + structure de dossiers + onboarding +guidé". Réutilise les fixtures de test_auth.py (inscription/2FA).""" +import os + +import auth +import db +import screens + +from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401 + + +def test_account_without_a_project_is_redirected_to_onboarding(anon_client): + _register(anon_client, "needsonboarding@example.com") + _confirm_2fa(anon_client) + try: + resp = anon_client.get("/", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == "/onboarding" + + resp = anon_client.get("/onboarding") + assert resp.status_code == 200 + assert "Quiz" in resp.get_data(as_text=True) + finally: + _cleanup_project("needsonboarding@example.com") + + +def test_quiz_lands_on_simplified_dashboard_with_one_document_screen(anon_client): + """Le dashboard simplifié (routes/games/game_dashboard.py) — un compte + restreint (quiz/embranchement/rpg) atterrit maintenant sur SON + tableau de bord (même route que "custom"), juste rendu en version + simplifiée : que des écrans en cartes, pas d'onglets Objets/Éléments + de jeu/Variables (réservés à "Créer mon jeu de A à Z").""" + _register(anon_client, "quizuser@example.com") + _confirm_2fa(anon_client) + try: + resp = _complete_onboarding(anon_client, name="Mon Quiz", onboarding_type="quiz") + assert resp.status_code == 302 + user = auth.get_user_by_email("quizuser@example.com") + slug = user["project_slug"] + assert resp.headers["Location"] == f"/game/{slug}" + assert db.get_onboarding_type(slug) == "quiz" + + screen_list = screens.list_screens(slug) + assert len(screen_list) == 1 + assert screen_list[0]["kind"] == "document" + + html = anon_client.get(f"/game/{slug}").get_data(as_text=True) + assert "screenCardGrid" in html + assert "+ Ajouter un écran" in html + assert 'name="kind"' not in html # pas de choix de type à l'ajout + assert "openNewObjectPanel" not in html # onglet Objets du dashboard complet absent + + # L'éditeur de l'écran, lui, reste atteignable comme avant. + edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True) + assert "scene-editor.js" not in edit_html + finally: + _cleanup_project("quizuser@example.com") + + +def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_client): + _register(anon_client, "rpguser@example.com") + _confirm_2fa(anon_client) + try: + resp = _complete_onboarding(anon_client, name="Mon RPG", onboarding_type="rpg") + slug = auth.get_user_by_email("rpguser@example.com")["project_slug"] + assert resp.headers["Location"] == f"/game/{slug}" + screen_list = screens.list_screens(slug) + assert screen_list[0]["kind"] == "jeu_2d" + + html = anon_client.get(f"/game/{slug}").get_data(as_text=True) + assert "screenPreviewScene" in html + assert 'name="kind"' not in html + + # "+ Ajouter un écran" ne propose pas de choix de type — imposé + # jeu_2d par le projet, même en tentant de forcer "document" via + # un POST direct (défense en profondeur, pas seulement l'UI). + anon_client.post(f"/game/{slug}/screens/new", data={"name": "Scène 2", "kind": "document"}, follow_redirects=False) + new_screen_list = screens.list_screens(slug) + assert len(new_screen_list) == 2 + assert new_screen_list[1]["kind"] == "jeu_2d" + + edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True) + assert "scene-editor.js" in edit_html + finally: + _cleanup_project("rpguser@example.com") + + +def test_custom_keeps_todays_full_dashboard_behavior(anon_client): + _register(anon_client, "customuser@example.com") + _confirm_2fa(anon_client) + try: + resp = _complete_onboarding(anon_client, name="Mon jeu complet", onboarding_type="custom") + assert resp.status_code == 302 + + slug = auth.get_user_by_email("customuser@example.com")["project_slug"] + assert resp.headers["Location"] == f"/game/{slug}" + + html = anon_client.get(f"/game/{slug}").get_data(as_text=True) + assert "Tableau de bord" in html or "gameNavLink" in html + finally: + _cleanup_project("customuser@example.com") + + +def test_onboarding_rejects_unknown_type(anon_client): + _register(anon_client, "badtype@example.com") + _confirm_2fa(anon_client) + try: + resp = anon_client.post("/onboarding", data={"onboarding_type": "n_importe_quoi", "name": "Test"}) + assert resp.status_code == 200 + assert auth.get_user_by_email("badtype@example.com")["project_slug"] is None + finally: + _cleanup_project("badtype@example.com") + + +def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client): + _register(anon_client, "revisit@example.com") + _confirm_2fa(anon_client) + try: + first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="custom") + first_slug = auth.get_user_by_email("revisit@example.com")["project_slug"] + + resp = anon_client.get("/onboarding", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == first.headers["Location"] + + again = anon_client.post("/onboarding", data={"onboarding_type": "quiz", "name": "Second jeu"}, follow_redirects=False) + assert auth.get_user_by_email("revisit@example.com")["project_slug"] == first_slug + assert again.headers["Location"] == first.headers["Location"] + finally: + _cleanup_project("revisit@example.com") + + +def test_admin_is_never_redirected_to_onboarding(client, game): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 200 + + +def test_admin_can_create_several_games_of_different_types_via_onboarding(client): + """Comportement systématique à CHAQUE création de jeu, pas une + formalité réservée à la toute première inscription (voir le plan) — + un admin, illimité (pas de project_slug dédié), doit pouvoir repasser + par /onboarding autant de fois qu'il crée un nouveau jeu, sans jamais + être bloqué/redirigé vers un projet précédent.""" + resp1 = _complete_onboarding(client, name="Jeu Quiz Admin", onboarding_type="quiz") + assert resp1.status_code == 302 + slug1 = resp1.headers["Location"].split("/game/", 1)[1].split("/", 1)[0] + slug2 = None + try: + # project_slug reste NON posé sur le compte admin (voir + # auth/create_user.py — colonne réservée à un compte "user" à + # projet unique) : repasser sur /onboarding ne redirige PAS vers + # le premier projet créé, la page de choix s'affiche à nouveau. + resp = client.get("/onboarding") + assert resp.status_code == 200 + assert "Quiz" in resp.get_data(as_text=True) + + resp2 = _complete_onboarding(client, name="Jeu RPG Admin", onboarding_type="rpg") + assert resp2.status_code == 302 + slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0] + assert slug2 != slug1 + + slugs = {g["slug"] for g in db.list_games()} + assert slug1 in slugs + assert slug2 in slugs + finally: + for slug in (slug1, slug2): + if slug and os.path.isdir(db.game_dir(slug)): + db.delete_game(slug) diff --git a/tests/test_profile.py b/tests/test_profile.py index 3f6699a5..a6e936df 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -5,8 +5,9 @@ import pyotp import auth import db +from db.games.project_slug import build_slug, split_slug -from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401 +from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401 def test_profile_page_shows_current_user_info(anon_client): @@ -122,6 +123,7 @@ def test_delete_account_requires_the_confirmation_phrase(anon_client): def test_delete_account_removes_user_and_their_project(anon_client): _register(anon_client, "deleteforreal@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("deleteforreal@example.com") slug = user["project_slug"] import os @@ -218,6 +220,7 @@ def test_update_email_renames_the_user_project_folder(anon_client): _register(anon_client, "oldmail@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) old_user = auth.get_user_by_email("oldmail@example.com") old_slug = old_user["project_slug"] assert os.path.isdir(db.game_dir(old_slug)) @@ -229,7 +232,10 @@ def test_update_email_renames_the_user_project_folder(anon_client): new_user = auth.get_user_by_email("newmail@example.com") assert new_user is not None new_slug = new_user["project_slug"] - assert new_slug == db.slugify("newmail@example.com") + # Structure de dossiers par utilisateur (voir db/games/ + # project_slug.py) : seul le dossier PROPRIÉTAIRE change de nom, + # le dossier projet (project_part) reste le même. + assert new_slug == build_slug(db.slugify("newmail@example.com"), split_slug(old_slug)[1]) assert not os.path.isdir(db.game_dir(old_slug)) assert os.path.isdir(db.game_dir(new_slug)) diff --git a/tests/test_publish.py b/tests/test_publish.py index a7a01801..d010c7d1 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -80,10 +80,11 @@ def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_clie code supplémentaire — un compte non-admin ne doit pas pouvoir publier le projet d'un autre (même vérification que /game/ lui-même, voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project).""" - from tests.test_auth import _register, _confirm_2fa, _cleanup_project + from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project _register(anon_client, "publishisolation@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) try: resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish") assert resp.status_code == 403 From 9776fbc0579465ad66d469390cc28a110ea34b83 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 06:23:37 +0200 Subject: [PATCH 04/22] Score/Progression (Phase 1.1 de la feuille de route produit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concept de premier ordre, distinct du système de variables globales — préalable identifié aux futurs exports SCORM/xAPI (note de cadrage .claude/Forge_Engine_Cadrage.pdf) : ces standards ont besoin d'un signal "score"/"terminé" propre, pas d'une convention sur une variable choisie par le créateur. - db/scoring/ (même patron que db/global_vars/) : table _scoring, un score numérique + un statut (non_commence/en_cours/termine/reussi/ echoue) par joueur (voir db.PLAYER_SHARED pour l'aperçu créateur). - Deux nouvelles actions de flux, dans les deux éditeurs (document et scène) : "Modifier le score" (réutilise le vocabulaire d'opérations déjà là pour "Modifier une variable" — incrémenter/définir/etc., aucune nouvelle colonne de nœud) et "Définir le statut de la partie". - Routes créateur (routes/flow/flow_node_run_score.py, flow_node_run_status.py) + miroirs publics par joueur (routes/public_play/) — même principe que flow_node_run_variable.py. - GET /game//scoring/ : lecture interne, pas exposée au joueur, préparée pour être consommée par le futur export Web/SCORM. Co-Authored-By: Claude Sonnet 5 --- db/constants.py | 14 ++ db/scoring/__init__.py | 6 + db/scoring/ensure_scoring_schema.py | 22 +++ db/scoring/get_score.py | 17 ++ db/scoring/set_score_value.py | 23 +++ db/scoring/set_status.py | 25 +++ routes/flow/__init__.py | 2 + routes/flow/flow_node_run_score.py | 18 +++ routes/flow/flow_node_run_status.py | 17 ++ routes/games/__init__.py | 2 +- routes/games/game_scoring_view.py | 16 ++ routes/play/game_play.py | 2 + routes/public_play/__init__.py | 2 + .../public_play/flow_node_run_score_public.py | 25 +++ .../flow_node_run_status_public.py | 25 +++ routes/public_play/game_play_public.py | 2 + screens/__init__.py | 2 + screens/data_actions/apply_score_action.py | 23 +++ screens/data_actions/apply_status_action.py | 13 ++ screens/labels/action_type_labels.py | 2 + screens/scenes/flow_palette.py | 2 + static/js/play/actions.js | 13 ++ static/js/screen_edit/flow-editor.js | 17 ++ templates/scene_edit.html | 22 +++ templates/screen_edit.html | 22 +++ tests/test_scoring.py | 153 ++++++++++++++++++ 26 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 db/scoring/__init__.py create mode 100644 db/scoring/ensure_scoring_schema.py create mode 100644 db/scoring/get_score.py create mode 100644 db/scoring/set_score_value.py create mode 100644 db/scoring/set_status.py create mode 100644 routes/flow/flow_node_run_score.py create mode 100644 routes/flow/flow_node_run_status.py create mode 100644 routes/games/game_scoring_view.py create mode 100644 routes/public_play/flow_node_run_score_public.py create mode 100644 routes/public_play/flow_node_run_status_public.py create mode 100644 screens/data_actions/apply_score_action.py create mode 100644 screens/data_actions/apply_status_action.py create mode 100644 tests/test_scoring.py diff --git a/db/constants.py b/db/constants.py index 4572394c..6489623c 100644 --- a/db/constants.py +++ b/db/constants.py @@ -41,3 +41,17 @@ GLOBAL_VARIABLE_TYPES = { "objet": {"label": "Objet (JSON)"}, "tableau": {"label": "Tableau (JSON)"}, } + +# Statuts de partie (voir db/scoring/) — préalable aux exports SCORM/xAPI, +# qui ont besoin d'un signal "terminé"/"réussi" propre plutôt que d'une +# convention sur une variable choisie par le créateur (voir le plan +# "Feuille de route produit"). "non_commence" est le défaut d'un joueur +# qui n'a encore rien écrit (voir db/scoring/get_score.py). +SCORE_STATUS_CHOICES = [ + ("non_commence", "Non commencée"), + ("en_cours", "En cours"), + ("termine", "Terminée"), + ("reussi", "Réussie"), + ("echoue", "Échouée"), +] +SCORE_STATUS_LABELS = dict(SCORE_STATUS_CHOICES) diff --git a/db/scoring/__init__.py b/db/scoring/__init__.py new file mode 100644 index 00000000..66c320c2 --- /dev/null +++ b/db/scoring/__init__.py @@ -0,0 +1,6 @@ +"""Score/progression (voir le plan "Feuille de route produit") : un +concept de premier ordre, distinct du système de variables globales +(db/global_vars/) — un score numérique et un statut de partie, TOUJOURS +par joueur (voir db.PLAYER_SHARED pour l'aperçu créateur), préalable aux +exports SCORM/xAPI (qui ont besoin d'un signal "score"/"terminé" propre, +pas d'une convention sur une variable choisie par le créateur).""" diff --git a/db/scoring/ensure_scoring_schema.py b/db/scoring/ensure_scoring_schema.py new file mode 100644 index 00000000..90a656ca --- /dev/null +++ b/db/scoring/ensure_scoring_schema.py @@ -0,0 +1,22 @@ +from ..connection import connect + + +def ensure_scoring_schema(slug): + """Migration légère (même principe que ensure_global_vars_schema.py) : + crée _scoring si absente. Une seule ligne par joueur (player_id, voir + db.PLAYER_SHARED pour l'aperçu créateur — même modèle que + db/global_vars/, mais pas de notion "per_player=0" ici : le score + d'UN joueur n'a de sens que pour CE joueur, jamais partagé).""" + conn = connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _scoring ( + player_id TEXT PRIMARY KEY, + score REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'non_commence', + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/db/scoring/get_score.py b/db/scoring/get_score.py new file mode 100644 index 00000000..dcda310b --- /dev/null +++ b/db/scoring/get_score.py @@ -0,0 +1,17 @@ +from ..connection import connect +from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .ensure_scoring_schema import ensure_scoring_schema + + +def get_score(slug, player_id=PLAYER_SHARED): + """Score/statut de CE joueur — (0, "non_commence") tant qu'il n'a + encore rien écrit (voir set_score_value.py/set_status.py, qui créent + la ligne à la première écriture, même patron que + update_global_variable_value.py).""" + ensure_scoring_schema(slug) + conn = connect(slug) + row = conn.execute("SELECT * FROM _scoring WHERE player_id = ?", (player_id,)).fetchone() + conn.close() + if not row: + return {"player_id": player_id, "score": 0, "status": "non_commence"} + return dict(row) diff --git a/db/scoring/set_score_value.py b/db/scoring/set_score_value.py new file mode 100644 index 00000000..323f50ed --- /dev/null +++ b/db/scoring/set_score_value.py @@ -0,0 +1,23 @@ +from ..connection import connect +from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .ensure_scoring_schema import ensure_scoring_schema + + +def set_score_value(slug, new_value, player_id=PLAYER_SHARED): + """Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant, voir + screens/data_actions/apply_score_action.py) — même patron que + update_global_variable_value.py : la ligne de ce joueur est créée à sa + toute première écriture (INSERT ... ON CONFLICT évite un aller-retour + SELECT préalable, un joueur ne peut écrire que la SIENNE).""" + ensure_scoring_schema(slug) + conn = connect(slug) + conn.execute( + """ + INSERT INTO _scoring (player_id, score) VALUES (?, ?) + ON CONFLICT(player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP + """, + (player_id, new_value), + ) + conn.commit() + conn.close() + return True diff --git a/db/scoring/set_status.py b/db/scoring/set_status.py new file mode 100644 index 00000000..a0cffa78 --- /dev/null +++ b/db/scoring/set_status.py @@ -0,0 +1,25 @@ +from ..connection import connect +from ..constants import SCORE_STATUS_LABELS +from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .ensure_scoring_schema import ensure_scoring_schema + + +def set_status(slug, status, player_id=PLAYER_SHARED): + """Voir set_score_value.py pour le patron (upsert, ligne créée à la + première écriture). `status` doit être une clé de + db.SCORE_STATUS_LABELS — un statut inconnu (ex. faute de frappe dans + une valeur de nœud de flux) est refusé plutôt qu'écrit tel quel.""" + if status not in SCORE_STATUS_LABELS: + return False + ensure_scoring_schema(slug) + conn = connect(slug) + conn.execute( + """ + INSERT INTO _scoring (player_id, status) VALUES (?, ?) + ON CONFLICT(player_id) DO UPDATE SET status = excluded.status, updated_at = CURRENT_TIMESTAMP + """, + (player_id, status), + ) + conn.commit() + conn.close() + return True diff --git a/routes/flow/__init__.py b/routes/flow/__init__.py index 9864306b..4d46c046 100644 --- a/routes/flow/__init__.py +++ b/routes/flow/__init__.py @@ -6,6 +6,8 @@ from . import ( flow_edge_delete, flow_node_run_data, flow_node_run_variable, + flow_node_run_score, + flow_node_run_status, flow_node_run_add_row, runtime_payload, ) diff --git a/routes/flow/flow_node_run_score.py b/routes/flow/flow_node_run_score.py new file mode 100644 index 00000000..8a0b80a5 --- /dev/null +++ b/routes/flow/flow_node_run_score.py @@ -0,0 +1,18 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//run-score", methods=["POST"]) +def flow_node_run_score(slug, node_id): + """Exécuté côté serveur au moment du clic, en mode jouable, pour un + nœud Action de type "Modifier le score" — même principe que + flow_node_run_variable.py, pour le score plutôt qu'une variable + globale (voir db/scoring/, screens/data_actions/apply_score_action.py).""" + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_score": + return jsonify({"ok": False}), 400 + ok = screens.apply_score_action(slug, node) + return jsonify({"ok": ok}) diff --git a/routes/flow/flow_node_run_status.py b/routes/flow/flow_node_run_status.py new file mode 100644 index 00000000..89af2bc1 --- /dev/null +++ b/routes/flow/flow_node_run_status.py @@ -0,0 +1,17 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//run-status", methods=["POST"]) +def flow_node_run_status(slug, node_id): + """Exécuté côté serveur au moment du clic, en mode jouable, pour un + nœud Action de type "Définir le statut de la partie" — voir + db/scoring/, screens/data_actions/apply_status_action.py.""" + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "definir_statut_partie": + return jsonify({"ok": False}), 400 + ok = screens.apply_status_action(slug, node) + return jsonify({"ok": ok}) diff --git a/routes/games/__init__.py b/routes/games/__init__.py index bfc0d253..2d18d699 100644 --- a/routes/games/__init__.py +++ b/routes/games/__init__.py @@ -1 +1 @@ -from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played +from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played, game_scoring_view diff --git a/routes/games/game_scoring_view.py b/routes/games/game_scoring_view.py new file mode 100644 index 00000000..c53ae8c8 --- /dev/null +++ b/routes/games/game_scoring_view.py @@ -0,0 +1,16 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//scoring/") +def game_scoring_view(slug, player_id): + """Lecture du score/statut d'UN joueur (voir db/scoring/) — route + interne (protégée comme toute route /game/, voir + core/auth_guard.py), pas destinée au joueur lui-même : préparée pour + être consommée par les futurs exports SCORM/xAPI (voir le plan + "Feuille de route produit"), qui ont besoin de lire ce signal une fois + la partie terminée.""" + return jsonify(db.get_score(slug, player_id)) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index fed186ca..4e7f8cba 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -21,5 +21,7 @@ def game_play(slug): runtime_payload_url=url_for("runtime_payload", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999), + flow_node_run_score_url=url_for("flow_node_run_score", slug=slug, node_id=999999), + flow_node_run_status_url=url_for("flow_node_run_status", slug=slug, node_id=999999), flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py index 6984c1bb..03b1db91 100644 --- a/routes/public_play/__init__.py +++ b/routes/public_play/__init__.py @@ -3,5 +3,7 @@ from . import ( runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public, + flow_node_run_score_public, + flow_node_run_status_public, flow_node_run_add_row_public, ) diff --git a/routes/public_play/flow_node_run_score_public.py b/routes/public_play/flow_node_run_score_public.py new file mode 100644 index 00000000..5c337186 --- /dev/null +++ b/routes/public_play/flow_node_run_score_public.py @@ -0,0 +1,25 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-score", methods=["POST"]) +def flow_node_run_score_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_score.py (aperçu + créateur) — voir routes/public_play/game_play_public.py pour le + principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_score": + return jsonify({"ok": False}), 400 + ok = screens.apply_score_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": ok})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/flow_node_run_status_public.py b/routes/public_play/flow_node_run_status_public.py new file mode 100644 index 00000000..c6238e0a --- /dev/null +++ b/routes/public_play/flow_node_run_status_public.py @@ -0,0 +1,25 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-status", methods=["POST"]) +def flow_node_run_status_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_status.py (aperçu + créateur) — voir routes/public_play/game_play_public.py pour le + principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "definir_statut_partie": + return jsonify({"ok": False}), 400 + ok = screens.apply_status_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": ok})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py index b142810d..06ba226c 100644 --- a/routes/public_play/game_play_public.py +++ b/routes/public_play/game_play_public.py @@ -29,6 +29,8 @@ def game_play_public(slug): runtime_payload_url=url_for("runtime_payload_public", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999), + flow_node_run_score_url=url_for("flow_node_run_score_public", slug=slug, node_id=999999), + flow_node_run_status_url=url_for("flow_node_run_status_public", slug=slug, node_id=999999), flow_node_run_add_row_url=url_for("flow_node_run_add_row_public", slug=slug, node_id=999999), ) ) diff --git a/screens/__init__.py b/screens/__init__.py index d39e440f..55997ef0 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -144,6 +144,8 @@ from .flow.blocks.blocks_view import blocks_view from .data_actions.apply_data_action import apply_data_action from .data_actions.apply_variable_action import apply_variable_action +from .data_actions.apply_score_action import apply_score_action +from .data_actions.apply_status_action import apply_status_action from .data_actions.apply_add_row_action import apply_add_row_action from .animations.ensure_animation_schema import ensure_animation_schema diff --git a/screens/data_actions/apply_score_action.py b/screens/data_actions/apply_score_action.py new file mode 100644 index 00000000..e08fee9c --- /dev/null +++ b/screens/data_actions/apply_score_action.py @@ -0,0 +1,23 @@ +import db + +from .compute_operation import compute_new_value + + +def apply_score_action(slug, action, player_id=db.PLAYER_SHARED): + """Exécute au moment du clic (mode jouable) une action + "modifier_score" — même vocabulaire d'opérations que + apply_variable_action.py (DATA_OPERATIONS), appliqué au score de CE + joueur (voir db/scoring/) plutôt qu'à une variable globale. Le score + est TOUJOURS numérique (pas de "type" à lire au préalable, contrairement + à une variable — is_decimal=True partout, un score entier reste exact + en float pour de petits nombres).""" + operation = action.get("data_operation") + raw_value = action.get("data_value") + if not operation: + return False + current = db.get_score(slug, player_id)["score"] or 0 + try: + new_value = compute_new_value(operation, current, raw_value, True) + except ValueError: + return False + return db.set_score_value(slug, new_value, player_id) diff --git a/screens/data_actions/apply_status_action.py b/screens/data_actions/apply_status_action.py new file mode 100644 index 00000000..47719d61 --- /dev/null +++ b/screens/data_actions/apply_status_action.py @@ -0,0 +1,13 @@ +import db + + +def apply_status_action(slug, action, player_id=db.PLAYER_SHARED): + """Exécute au moment du clic (mode jouable) une action + "definir_statut_partie" — réutilise data_value (déjà un simple champ + texte générique sur le nœud action, comme pour "attendre") pour porter + la clé du statut choisi (voir db.SCORE_STATUS_LABELS), plutôt qu'une + nouvelle colonne de nœud dédiée.""" + status = action.get("data_value") + if not status: + return False + return db.set_status(slug, status, player_id) diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index 5d78197c..3b5620cf 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -5,6 +5,8 @@ ACTION_TYPE_LABELS = { "modifier_element": "Modifier un élément", "modifier_donnee": "Modifier une donnée d'un objet", "modifier_variable": "Modifier une variable globale", + "modifier_score": "Modifier le score", + "definir_statut_partie": "Définir le statut de la partie", "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", "activer_onglet": "Afficher cet élément, masquer tous ses frères", "attendre": "Attendre quelques secondes avant de continuer", diff --git a/screens/scenes/flow_palette.py b/screens/scenes/flow_palette.py index 778ac0d3..1cf47c0d 100644 --- a/screens/scenes/flow_palette.py +++ b/screens/scenes/flow_palette.py @@ -20,6 +20,8 @@ ACTION_TYPE_LABELS_2D = { "modifier_objet_scene": "Modifier un objet de scène", "modifier_variable": "Modifier une variable globale", "modifier_donnee": "Modifier une donnée d'un objet", + "modifier_score": "Modifier le score", + "definir_statut_partie": "Définir le statut de la partie", "jouer_animation_sprite": "Jouer une animation de sprites", "jouer_son": "Jouer un son", "declencher_evenement": "Déclencher un événement", diff --git a/static/js/play/actions.js b/static/js/play/actions.js index d2c53355..339916a5 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -268,6 +268,19 @@ function runActionNode(node) { return fetch(runVarUrl, {method: 'POST'}) .then(r => r.json()) .then(() => refreshRuntimeData()); + } else if (node.action_type === 'modifier_score') { + // Score/progression (voir db/scoring/, screens/data_actions/ + // apply_score_action.py) : même patron que "Modifier une variable + // globale" ci-dessus, calculé et écrit entièrement côté serveur. + const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id); + return fetch(runScoreUrl, {method: 'POST'}) + .then(r => r.json()) + .then(() => refreshRuntimeData()); + } else if (node.action_type === 'definir_statut_partie') { + const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id); + return fetch(runStatusUrl, {method: 'POST'}) + .then(r => r.json()) + .then(() => refreshRuntimeData()); } else if (node.action_type === 'ajouter_ligne') { // Phase 4 — crée une ligne VIDE côté serveur ; son id devient // window.lastInsertedRowId pour que les nœuds "Modifier une donnée" diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index afb81796..771ffbf4 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -134,6 +134,11 @@ function nodeLabel(node) { } else if (node.action_type === 'modifier_variable') { base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation); if (node.data_value) base += ' ' + node.data_value; + } else if (node.action_type === 'modifier_score') { + base += ' → ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation); + if (node.data_value) base += ' ' + node.data_value; + } else if (node.action_type === 'definir_statut_partie') { + base += ' → ' + (SCORE_STATUS_LABELS_MAP[node.data_value] || node.data_value || '?'); } else if (node.action_type === 'attendre') { base += ' (' + (node.data_value || '0') + ' s)'; } else if (node.action_type === 'declencher_evenement') { @@ -536,6 +541,8 @@ function toggleFlowActionFields() { var objectFields = document.getElementById('nf-action-object-fields'); var dataFields = document.getElementById('nf-action-data-fields'); var variableFields = document.getElementById('nf-action-variable-fields'); + var scoreFields = document.getElementById('nf-action-score-fields'); + var statusFields = document.getElementById('nf-action-status-fields'); var waitFields = document.getElementById('nf-action-wait-fields'); var eventFields = document.getElementById('nf-action-event-fields'); var addRowFields = document.getElementById('nf-action-add-row-fields'); @@ -555,6 +562,8 @@ function toggleFlowActionFields() { if (objectFields) objectFields.style.display = typeSel.value === 'modifier_objet_scene' ? 'block' : 'none'; if (dataFields) dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; if (variableFields) variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; + if (scoreFields) scoreFields.style.display = typeSel.value === 'modifier_score' ? 'block' : 'none'; + if (statusFields) statusFields.style.display = typeSel.value === 'definir_statut_partie' ? 'block' : 'none'; if (waitFields) waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none'; if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none'; @@ -567,6 +576,7 @@ function toggleFlowActionFields() { if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action'); updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value'); updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value'); + updateDataValuePlaceholder('nf-action-score-operation', 'nf-action-score-value'); } // Fondations multi-éditeurs — équivalent de toggleFlowElementPropertyValue() @@ -811,6 +821,13 @@ function submitNodeForm() { payload.target_variable = document.getElementById('nf-action-variable-name').value; payload.data_operation = document.getElementById('nf-action-variable-operation').value; payload.data_value = document.getElementById('nf-action-variable-value').value; + } else if (actionType === 'modifier_score') { + // Score/progression (voir db/scoring/) : un seul score par joueur, + // pas de nom à transmettre (contrairement à "modifier_variable"). + payload.data_operation = document.getElementById('nf-action-score-operation').value; + payload.data_value = document.getElementById('nf-action-score-value').value; + } else if (actionType === 'definir_statut_partie') { + payload.data_value = document.getElementById('nf-action-status-value').value; } else if (actionType === 'attendre') { payload.data_value = document.getElementById('nf-action-wait-seconds').value; } else if (actionType === 'declencher_evenement') { diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 80aadd56..bd4c7aef 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -400,6 +400,27 @@ {% endif %}
+ + + + + + + + + + +
+
+
+ {% endif %} {% block content %}{% endblock %} diff --git a/templates/play.html b/templates/play.html index 2c13250f..309a434c 100644 --- a/templates/play.html +++ b/templates/play.html @@ -4,12 +4,12 @@ {{ game.name }} — Jouer - - + + - - - - diff --git a/tests/test_background_scene_object.py b/tests/test_background_scene_object.py new file mode 100644 index 00000000..2d862334 --- /dev/null +++ b/tests/test_background_scene_object.py @@ -0,0 +1,102 @@ +"""Objet de scène kind="fond" (image de fond, voir +screens/scenes/add_scene_object.py) : posé à sa taille RÉELLE (voir +screens/labels/background_library.py), derrière tout le reste +(z_index très négatif), insensible au clic — et si plus grand que la +scène, "monde" que la caméra suit en défilant derrière le personnage +(voir static/js/play/personnage-controller.js, testé côté client dans +static/js/play/__tests__/personnage-controller.test.js).""" +import re + +import screens + + +def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_background"): + resp = client.post("/games/new", data={"name": name}, follow_redirects=False) + slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + return slug, screen_id + + +def _a_background_slug(): + assert screens.BACKGROUND_LIBRARY, "aucune image de fond générée — lancer scripts/generate_background_manifest.py" + return next(iter(screens.BACKGROUND_LIBRARY)) + + +def test_background_library_is_built_from_the_generated_manifest(): + slug = _a_background_slug() + background = screens.BACKGROUND_LIBRARY[slug] + assert background["url"].startswith("/static/backgrounds/") + assert background["width"] > 0 and background["height"] > 0 + + +def test_adding_a_fond_object_uses_the_image_real_size_and_sits_behind_everything(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + background_slug = _a_background_slug() + background = screens.BACKGROUND_LIBRARY[background_slug] + + resp = client.post( + f"/game/{slug}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": background_slug}, + follow_redirects=False, + ) + assert resp.status_code == 302, resp.data + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + obj = screens.get_scene_object(slug, obj_id) + assert obj["kind"] == "fond" + assert obj["width"] == background["width"] + assert obj["height"] == background["height"] + assert obj["x"] == 0 and obj["y"] == 0 + assert obj["z_index"] < 0 # toujours derrière un personnage/décor ajouté normalement (z_index >= 1) + assert obj["attributes"]["src"] == background["url"] + + +def test_fond_object_renders_as_an_image_with_the_sceneBackground_class(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + background_slug = _a_background_slug() + resp = client.post( + f"/game/{slug}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": background_slug}, + follow_redirects=False, + ) + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + obj = screens.get_scene_object(slug, obj_id) + html = screens.render_scene_object(obj) + assert 'class="sceneObjectSprite sceneBackground"' in html + assert f'data-object-id="{obj_id}"' in html + + +def test_fond_object_falls_back_to_default_size_without_a_known_background_slug(client, tmp_game_slug_cleanup): + """Un slug invalide/absent (URL bidouillée, script de manifeste jamais + lancé sur ce poste) : l'objet est quand même créé, à la taille par + défaut générique (128x128, voir ensure_scene_schema.py) plutôt que de + planter.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + resp = client.post( + f"/game/{slug}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": "ce-slug-nexiste-pas"}, + follow_redirects=False, + ) + assert resp.status_code == 302 + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + obj = screens.get_scene_object(slug, obj_id) + assert obj["width"] == 128 and obj["height"] == 128 + + +def test_background_gallery_route_rejects_a_non_admin_account(user_game, user_client): + resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False) + screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + background_slug = _a_background_slug() + resp = user_client.post( + f"/game/{user_game}/screens/{screen_id}/scene-objects/add", + data={"kind": "fond", "background_slug": background_slug}, + follow_redirects=False, + ) + assert resp.status_code == 403 + + +def test_background_gallery_hidden_from_non_admin_scene_editor(user_game, user_client): + resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False) + screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True) + assert "Images de fond" not in html From bc3feb508033284c728b10f180272fe248d627e0 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 15:30:34 +0200 Subject: [PATCH 14/22] =?UTF-8?q?Panneau=20sc=C3=A8ne=20simplifi=C3=A9=20+?= =?UTF-8?q?=20bo=C3=AEte=20de=20collision=20+=20r=C3=B4le=20joueur/ennemi/?= =?UTF-8?q?pnj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois retours utilisateur sur l'éditeur de scène 2D : - Retire l'arborescence "Objets de cette scène" (un objet reste sélectionnable en cliquant dessus sur le canevas) et le bouton "+ Ajouter un décor" (le type "decor" reste supporté côté serveur, juste plus accessible depuis ce menu). - Chaque objet de scène a désormais une boîte de collision AUTOMATIQUE (= sa propre boîte, comportement inchangé pour une condition de collision déjà posée) réglable dans un nouveau panneau "🧱 Collision" : activée/désactivée, forme (rectangle/cercle), taille et décalage — utile pour un sprite très paddé (CraftPix) dont la silhouette réelle est bien plus petite que son canevas. elementsOverlap() (conditions.js) applique ces réglages en restant identique par défaut. - Nouveau champ "🏷️ Rôle" (Joueur/Ennemi/PNJ) sur un personnage : SEUL un personnage "Joueur" est désormais déplacé/animé au clavier et suivi par la caméra (personnage-controller.js) — "Ennemi"/"PNJ" restent immobiles tant qu'aucune logique de flow ne les pilote (déplacement ennemi automatique/apparition, dialogue de PNJ... hors scope de ce réglage, qui ne fait qu'identifier "quel objet est le joueur"). Co-Authored-By: Claude Sonnet 5 --- routes/scenes/__init__.py | 2 + routes/scenes/scene_edit_view.py | 14 ++- routes/scenes/scene_object_collision.py | 34 ++++++ routes/scenes/scene_object_role.py | 22 ++++ screens/__init__.py | 4 + screens/payload/full_game_payload.py | 15 ++- screens/rendering/collision_settings.py | 40 +++++++ screens/rendering/personnage_role.py | 20 ++++ screens/scenes/set_scene_object_collision.py | 24 ++++ screens/scenes/set_scene_object_role.py | 25 ++++ static/js/play/__tests__/conditions.test.js | 37 ++++++ .../__tests__/personnage-controller.test.js | 30 +++-- static/js/play/conditions.js | 72 ++++++++++-- static/js/play/personnage-controller.js | 11 +- static/js/scenes/scene-editor.js | 35 ++++++ static/style.css | 17 ++- templates/scene_edit.html | 77 ++++++++++--- tests/test_collision_settings.py | 107 ++++++++++++++++++ tests/test_personnage_role.py | 65 +++++++++++ 19 files changed, 602 insertions(+), 49 deletions(-) create mode 100644 routes/scenes/scene_object_collision.py create mode 100644 routes/scenes/scene_object_role.py create mode 100644 screens/rendering/collision_settings.py create mode 100644 screens/rendering/personnage_role.py create mode 100644 screens/scenes/set_scene_object_collision.py create mode 100644 screens/scenes/set_scene_object_role.py create mode 100644 tests/test_collision_settings.py create mode 100644 tests/test_personnage_role.py diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index fea9a00d..85fc3d14 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -4,4 +4,6 @@ from . import ( scene_object_delete, scene_object_personnage_data, scene_object_commands, + scene_object_role, + scene_object_collision, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 6f238392..ff3dcef0 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -24,10 +24,15 @@ def render_scene_edit(slug, screen_id, game): selected_id = request.args.get("selected", type=int) personnage_data = None personnage_commands = None + personnage_role = None + collision_settings = None selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None - if selected_obj and selected_obj["kind"] == "personnage": - personnage_data = screens.resolve_personnage_data(selected_obj) - personnage_commands = screens.resolve_personnage_commands(selected_obj) + if selected_obj: + 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) _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} object_labels = { @@ -64,6 +69,9 @@ def render_scene_edit(slug, screen_id, game): game=game, screen=screen, all_screens=all_screens, objects=objects, selected_id=selected_id, personnage_data=personnage_data, personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS, + 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, sprite_library=sprite_library, sprite_library_families=sprite_library_families, sprite_library_json=json.dumps(sprite_library), background_library=background_library, diff --git a/routes/scenes/scene_object_collision.py b/routes/scenes/scene_object_collision.py new file mode 100644 index 00000000..2dcf2f81 --- /dev/null +++ b/routes/scenes/scene_object_collision.py @@ -0,0 +1,34 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//collision", methods=["POST"]) +def scene_object_collision(slug, object_id): + """Boîte de collision d'un objet de scène (voir "🧱 Collision" dans + templates/scene_edit.html, static/js/play/conditions.js:: + elementsOverlap pour son usage au jeu).""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + + settings = {"enabled": bool(body.get("enabled", True))} + settings["shape"] = body.get("shape") if body.get("shape") in screens.COLLISION_SHAPES else "rectangle" + for key in ("width", "height"): + try: + value = body.get(key) + settings[key] = float(value) if value not in (None, "") else None + except (TypeError, ValueError): + settings[key] = None + for key in ("offset_x", "offset_y"): + try: + settings[key] = float(body.get(key) or 0) + except (TypeError, ValueError): + settings[key] = 0 + + screens.set_scene_object_collision(slug, object_id, settings) + obj = screens.get_scene_object(slug, object_id) + return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)}) diff --git a/routes/scenes/scene_object_role.py b/routes/scenes/scene_object_role.py new file mode 100644 index 00000000..83dbbd88 --- /dev/null +++ b/routes/scenes/scene_object_role.py @@ -0,0 +1,22 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//role", methods=["POST"]) +def scene_object_role(slug, object_id): + """Rôle d'un personnage (joueur/ennemie/pnj — voir "🏷️ Rôle" dans le + panneau Personnage, templates/scene_edit.html) : seul un personnage + "joueur" est déplacé/animé automatiquement au clavier (voir + static/js/play/personnage-controller.js).""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + role = body.get("role") + if role not in screens.PERSONNAGE_ROLES: + role = screens.DEFAULT_PERSONNAGE_ROLE + screens.set_scene_object_role(slug, object_id, role) + return jsonify({"ok": True, "role": role}) diff --git a/screens/__init__.py b/screens/__init__.py index fc7867e6..5638a527 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -81,6 +81,8 @@ from .flow.constants import ( 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 +from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .screens_repo.ensure_schema import ensure_schema @@ -116,6 +118,8 @@ from .scenes.update_scene_object_geometry import update_scene_object_geometry from .scenes.delete_scene_object import delete_scene_object from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data 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.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 3dac0424..8fb6464a 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -9,6 +9,8 @@ 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 +from ..rendering.collision_settings import resolve_collision_settings from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -47,11 +49,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): o["rendered_html"] = render_scene_object(o) # Déplacement/animation automatiques (voir # static/js/play/personnage-controller.js) : chaque - # personnage porte ses touches déjà résolues (défauts - # comblés) pour que le runtime client n'ait aucune logique - # de repli à dupliquer. + # 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) + # 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) payload_screens.append({**s, "elements": objects}) else: elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id) diff --git a/screens/rendering/collision_settings.py b/screens/rendering/collision_settings.py new file mode 100644 index 00000000..54c4d7ff --- /dev/null +++ b/screens/rendering/collision_settings.py @@ -0,0 +1,40 @@ +import json + +# Boîte de collision d'un objet de scène (voir screens/scenes/ +# set_scene_object_collision.py) : posée AUTOMATIQUEMENT sur chaque objet +# (rectangle, taille = la boîte de l'objet lui-même, centrée dessus, sans +# décalage — comportement inchangé pour une condition de collision +# existante, voir elementsOverlap() dans static/js/play/conditions.js), +# réglable ensuite dans le panneau de propriétés : activée/désactivée, +# forme (rectangle/cercle) et taille/décalage propres, pour un sprite très +# paddé (ex. les frames CraftPix) dont la silhouette réelle est bien plus +# petite que son canevas. +DEFAULT_COLLISION_SETTINGS = { + "enabled": True, "shape": "rectangle", "width": None, "height": None, + "offset_x": 0, "offset_y": 0, +} +COLLISION_SHAPES = ["rectangle", "cercle"] + + +def resolve_collision_settings(obj): + """Décode attributes._collision en dict Python, jamais None — un + attribut absent/corrompu retombe sur le défaut (boîte de collision = + la boîte entière de l'objet, comme si ce réglage n'existait pas).""" + raw = (obj.get("attributes") or {}).get("_collision") + settings = dict(DEFAULT_COLLISION_SETTINGS) + if raw: + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + parsed = None + if isinstance(parsed, dict): + for key in DEFAULT_COLLISION_SETTINGS: + if key in parsed: + settings[key] = parsed[key] + if settings["shape"] not in COLLISION_SHAPES: + settings["shape"] = "rectangle" + if not settings["width"]: + settings["width"] = obj.get("width") + if not settings["height"]: + settings["height"] = obj.get("height") + return settings diff --git a/screens/rendering/personnage_role.py b/screens/rendering/personnage_role.py new file mode 100644 index 00000000..8a6fb75a --- /dev/null +++ b/screens/rendering/personnage_role.py @@ -0,0 +1,20 @@ +# Rôle d'un personnage posé sur une scène 2D (voir +# screens/scenes/set_scene_object_role.py) : seul un personnage "joueur" +# est déplacé/animé automatiquement au clavier et suivi par la caméra +# (voir static/js/play/personnage-controller.js) — "ennemie"/"pnj" restent +# immobiles tant qu'aucune logique de flow ne les pilote (déplacement +# ennemi automatique/apparition, dialogue de PNJ... : hors scope de ce +# premier réglage, seulement l'étiquette qui permettra de les construire +# ensuite sans ambiguïté sur "quel objet est le joueur"). +PERSONNAGE_ROLES = ["joueur", "ennemie", "pnj"] +PERSONNAGE_ROLE_LABELS = {"joueur": "Joueur", "ennemie": "Ennemi", "pnj": "PNJ"} +DEFAULT_PERSONNAGE_ROLE = "joueur" + + +def resolve_personnage_role(el): + """Décode attributes._personnage_role — un attribut absent/corrompu + retombe sur "joueur" (comportement inchangé pour tout personnage posé + avant l'ajout de ce réglage : c'était déjà, de fait, le seul rôle qui + existait — tout personnage se déplaçait au clavier).""" + role = (el.get("attributes") or {}).get("_personnage_role") + return role if role in PERSONNAGE_ROLES else DEFAULT_PERSONNAGE_ROLE diff --git a/screens/scenes/set_scene_object_collision.py b/screens/scenes/set_scene_object_collision.py new file mode 100644 index 00000000..6f995b29 --- /dev/null +++ b/screens/scenes/set_scene_object_collision.py @@ -0,0 +1,24 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_collision(slug, object_id, settings): + """Enregistre la boîte de collision (activée/désactivée, forme, + taille, décalage — voir screens/rendering/collision_settings.py) d'un + objet de scène QUELCONQUE (pas seulement "personnage" : un décor/fond + peut aussi servir d'obstacle à une condition de collision) — un + attribut JSON distinct de tous les autres réglages d'objet.""" + 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 + attributes = json.loads(row["attributes"] or "{}") + attributes["_collision"] = json.dumps(settings) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/screens/scenes/set_scene_object_role.py b/screens/scenes/set_scene_object_role.py new file mode 100644 index 00000000..f67c8685 --- /dev/null +++ b/screens/scenes/set_scene_object_role.py @@ -0,0 +1,25 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_role(slug, object_id, role): + """Enregistre le rôle (joueur/ennemie/pnj, voir + screens/rendering/personnage_role.py) d'un objet de scène + kind="personnage" — un attribut DISTINCT de _personnage_data (choix du + personnage) et _personnage_commandes (touches), pour ne jamais les + écraser l'un l'autre à l'enregistrement (même raison que + set_scene_object_commands.py).""" + 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 + attributes = json.loads(row["attributes"] or "{}") + attributes["_personnage_role"] = role + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/static/js/play/__tests__/conditions.test.js b/static/js/play/__tests__/conditions.test.js index f2d32384..32edf2a1 100644 --- a/static/js/play/__tests__/conditions.test.js +++ b/static/js/play/__tests__/conditions.test.js @@ -13,6 +13,9 @@ const { readVariableValue, evaluateConditionClause, evaluateConditionNode, + forgeCollisionRectFromBox, + forgeRectanglesOverlap, + forgeCirclesOverlap, } = require('../conditions.js'); function withGameData(data, fn) { @@ -117,3 +120,37 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', assert.equal(evaluateConditionNode(node), true); }); }); + +// Boîte de collision (voir screens/rendering/collision_settings.py) — +// demandé par l'utilisateur : chaque objet a automatiquement une boîte de +// collision (= sa boîte visuelle par défaut), configurable (taille, +// forme, activée/désactivée). +test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => { + const box = { left: 10, top: 20, width: 40, height: 60 }; + assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60 }); +}); + +test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => { + const box = { left: 0, top: 0, width: 100, height: 100 }; + const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 }); + assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20 }); +}); + +test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => { + const box = { left: 0, top: 0, width: 100, height: 100 }; + const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 }); + assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20 }); +}); + +test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => { + assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 5, top: 5, right: 15, bottom: 15 }), true); + assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 20, top: 20, right: 30, bottom: 30 }), false); +}); + +test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => { + const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20 }; // centre (10,10), rayon 10 + const touching = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20 }; // centre (25,10), rayon 10, distance 15 < 20 + const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20 }; + assert.equal(forgeCirclesOverlap(a, touching), true); + assert.equal(forgeCirclesOverlap(a, apart), false); +}); diff --git a/static/js/play/__tests__/personnage-controller.test.js b/static/js/play/__tests__/personnage-controller.test.js index 40edf822..fb12a137 100644 --- a/static/js/play/__tests__/personnage-controller.test.js +++ b/static/js/play/__tests__/personnage-controller.test.js @@ -53,7 +53,7 @@ test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux s }); test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => { - const obj = { id: 10, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const obj = { id: 10, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; const el = fakeSceneEl(); withPersonnageObject(obj, { 10: el }, () => { global.heldKeys = new Set(['d']); @@ -64,7 +64,7 @@ test('tick — touche "droite" enfoncée : déplace vers la droite et retire le }); test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => { - const obj = { id: 11, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const obj = { id: 11, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; const el = fakeSceneEl(); withPersonnageObject(obj, { 11: el }, () => { global.heldKeys = new Set(['q']); @@ -75,7 +75,7 @@ test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique l }); test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => { - const obj = { id: 12, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } }; + const obj = { id: 12, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } }; const el = fakeSceneEl(); withPersonnageObject(obj, { 12: el }, () => { global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe @@ -85,7 +85,7 @@ test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfonc }); test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => { - const obj = { id: 13, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } }; + const obj = { id: 13, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } }; const el = fakeSceneEl(); withPersonnageObject(obj, { 13: el }, () => { global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe @@ -95,7 +95,7 @@ test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfo }); test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => { - const obj = { id: 14, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } }; + const obj = { id: 14, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } }; const el = fakeSceneEl(); el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max withPersonnageObject(obj, { 14: el }, () => { @@ -106,7 +106,7 @@ test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () = }); test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => { - const obj = { id: 15, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const obj = { id: 15, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; const el = fakeSceneEl(); withPersonnageObject(obj, { 15: el }, () => { global.heldKeys = new Set(); @@ -126,6 +126,20 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman }); }); +// Rôle (voir screens/rendering/personnage_role.py) — demandé par +// l'utilisateur : seul un personnage "joueur" doit être déplacé/animé au +// clavier, "ennemie"/"pnj" doivent rester immobiles tant qu'aucune +// logique de flow ne les pilote. +test('tick — un personnage "ennemie"/"pnj" n\'est jamais déplacé au clavier', () => { + const ennemi = { id: 17, kind: 'personnage', personnage_role: 'ennemie', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const el = fakeSceneEl(); + withPersonnageObject(ennemi, { 17: el }, () => { + global.heldKeys = new Set(['d']); + forgeRunPersonnageControllerTick({ elements: [ennemi] }); + assert.equal(el.style.left, '100px'); // inchangé, malgré la touche "droite" enfoncée + }); +}); + // Touches supplémentaires (sauter, attaquer...) — bug remonté par // l'utilisateur : au-delà des 4 touches de déplacement et de "interagir", // il manquait un moyen de lier une touche à N'IMPORTE QUELLE autre pose du @@ -188,7 +202,7 @@ test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux b id: 1, scene_width: 500, scene_height: 500, elements: [ { kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }, - { id: 42, kind: 'personnage' }, + { id: 42, kind: 'personnage', personnage_role: 'joueur' }, ], }; try { @@ -212,7 +226,7 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b id: 1, scene_width: 500, scene_height: 500, elements: [ { kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }, - { id: 42, kind: 'personnage' }, + { id: 42, kind: 'personnage', personnage_role: 'joueur' }, ], }; try { diff --git a/static/js/play/conditions.js b/static/js/play/conditions.js index 8aa7b550..bbeee776 100644 --- a/static/js/play/conditions.js +++ b/static/js/play/conditions.js @@ -101,19 +101,68 @@ function resolveVariablePath(rawValue, path) { // comportement historique), soit une VARIABLE GLOBALE (source // "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/ // cond_variable_chemin). -// Phase 5 — condition de collision : chevauchement des rectangles de -// deux éléments (getBoundingClientRect, donc leur position RÉELLE à -// l'écran — reflète les déplacements posés via "Modifier un élément → -// Position", voir static/js/play/actions.js). Aucun élément trouvé (id -// invalide, élément masqué avec display:none — getBoundingClientRect -// renvoie alors une boîte de largeur/hauteur nulles) => pas de -// chevauchement plutôt que de planter. +// Boîte de collision (voir screens/rendering/collision_settings.py) : +// centrée par défaut sur le rectangle VISUEL (getBoundingClientRect), +// réduite/décalée si réglée dans le panneau de propriétés — un sprite +// très paddé (ex. les frames CraftPix) peut ainsi avoir une silhouette de +// collision bien plus petite que son canevas. `collision` absent (élément +// de l'éditeur document, ou objet de scène posé avant l'ajout de ce +// réglage) => le rectangle visuel EXACT, comportement inchangé. Fonction +// PURE (aucun accès DOM) — testée par static/js/play/__tests__/ +// conditions.test.js, contrairement à elementsOverlap() ci-dessous. +function forgeCollisionRectFromBox(box, collision) { + const width = (collision && collision.width) || box.width; + const height = (collision && collision.height) || box.height; + const offsetX = (collision && collision.offset_x) || 0; + const offsetY = (collision && collision.offset_y) || 0; + const left = box.left + (box.width - width) / 2 + offsetX; + const top = box.top + (box.height - height) / 2 + offsetY; + return { left, top, right: left + width, bottom: top + height, width, height }; +} + +function forgeRectanglesOverlap(a, b) { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +// Cercle-cercle uniquement (rayon = le plus petit des deux côtés/2) — une +// paire rectangle/cercle retombe sur un chevauchement de rectangles (repli +// volontairement approximatif plutôt qu'une vraie intersection cercle- +// rectangle, hors scope de ce premier réglage). +function forgeCirclesOverlap(a, b) { + const acx = a.left + a.width / 2, acy = a.top + a.height / 2, ar = Math.min(a.width, a.height) / 2; + const bcx = b.left + b.width / 2, bcy = b.top + b.height / 2, br = Math.min(b.width, b.height) / 2; + const dx = acx - bcx, dy = acy - bcy; + return Math.sqrt(dx * dx + dy * dy) < (ar + br); +} + +// Phase 5 — condition de collision : chevauchement des BOÎTES DE +// COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments, +// positionnées sur leur rectangle RÉEL à l'écran (getBoundingClientRect — +// reflète les déplacements posés via "Modifier un élément"/"Modifier un +// objet de scène", voir static/js/play/actions.js, ET la caméra qui suit +// le personnage, voir personnage-controller.js : un décalage de caméra +// est un simple transform CSS, déjà reflété par getBoundingClientRect). +// Aucun élément trouvé (id invalide, élément masqué avec display:none — +// getBoundingClientRect renvoie alors une boîte de largeur/hauteur +// nulles), ou collision explicitement désactivée => pas de chevauchement +// plutôt que de planter. function elementsOverlap(elementIdA, elementIdB) { const a = document.querySelector('[data-element-id="' + elementIdA + '"]'); const b = document.querySelector('[data-element-id="' + elementIdB + '"]'); if (!a || !b) return false; - const r1 = a.getBoundingClientRect(), r2 = b.getBoundingClientRect(); - return r1.left < r2.right && r1.right > r2.left && r1.top < r2.bottom && r1.bottom > r2.top; + const screenData = (typeof screensData !== 'undefined' && typeof window !== 'undefined') + && screensData.find(function (s) { return s.id === window.currentScreenId; }); + const objA = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdA; }); + const objB = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdB; }); + if ((objA && objA.collision && objA.collision.enabled === false) || (objB && objB.collision && objB.collision.enabled === false)) { + return false; + } + const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision); + const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision); + const shapeA = (objA && objA.collision && objA.collision.shape) || 'rectangle'; + const shapeB = (objB && objB.collision && objB.collision.shape) || 'rectangle'; + if (shapeA === 'cercle' && shapeB === 'cercle') return forgeCirclesOverlap(rectA, rectB); + return forgeRectanglesOverlap(rectA, rectB); } function evaluateConditionClause(clause) { @@ -156,5 +205,8 @@ function evaluateConditionNode(node) { // require() — pas de risque en navigateur : `module` n'existe pas là-bas, // cette branche ne s'exécute jamais côté client. if (typeof module !== 'undefined' && module.exports) { - module.exports = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode }; + module.exports = { + readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode, + forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, + }; } diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 9b619693..23808699 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -77,7 +77,7 @@ function forgeUpdateSceneCamera(screenData) { worldEl.style.transform = ''; return; } - var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage'; }); + var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]'); var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2; var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2; @@ -102,7 +102,10 @@ function forgeStopPersonnageControllers() { function forgeRunPersonnageControllerTick(screenData) { forgeUpdateSceneCamera(screenData); (screenData.elements || []).forEach(function (obj) { - if (obj.kind !== 'personnage' || !obj.personnage_commandes) return; + // Seul le personnage "joueur" (voir screens/rendering/personnage_role.py) + // est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant + // qu'aucune logique de flow ne les pilote (voir add_scene_object.py). + if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return; var cmd = obj.personnage_commandes; var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); if (!targetEl) return; @@ -153,7 +156,7 @@ function forgePersonnageActionAnim(cmd, key) { function forgeStartPersonnageInteractListener(screenData) { activePersonnageInteractListener = function (ev) { (screenData.elements || []).forEach(function (obj) { - if (obj.kind !== 'personnage' || !obj.personnage_commandes) return; + if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return; var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key); if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return; var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); @@ -171,7 +174,7 @@ function forgeStartPersonnageControllers(screenData) { forgeStopPersonnageControllers(); if (!screenData || screenData.kind !== 'jeu_2d') return; var elements = screenData.elements || []; - var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage'; }); + var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); var hasFond = elements.some(function (o) { return o.kind === 'fond'; }); // Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms // plus tard) pour éviter un flash d'une frame à la taille/caméra par diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index ec2e1bdd..b695bb3b 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -145,6 +145,41 @@ function swapSceneObjectCharacter(forgeCharacter) { }); } +// Rôle (voir screens/rendering/personnage_role.py) : seul un personnage +// "Joueur" est déplacé/animé automatiquement au clavier — même patron AJAX +// que swapSceneObjectCharacter() ci-dessus (POST JSON, aucun rechargement +// de page), sur routes/scenes/scene_object_role.py. +function saveSceneObjectRole(role) { + var body = document.getElementById('personnagePanelBody'); + if (!body) return; + var objectId = parseInt(body.dataset.elementId, 10); + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: role }), + }); +} + +// Boîte de collision (voir screens/rendering/collision_settings.py, +// static/js/play/conditions.js::elementsOverlap) : disponible pour TOUT +// objet de scène (pas seulement "personnage") — même patron AJAX que les +// fonctions ci-dessus, sur routes/scenes/scene_object_collision.py. +function saveSceneObjectCollision() { + var body = document.getElementById('collisionPanelBody'); + if (!body) return; + var objectId = parseInt(body.dataset.elementId, 10); + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: document.getElementById('collision-enabled').checked, + shape: document.getElementById('collision-shape').value, + width: document.getElementById('collision-width').value, + height: document.getElementById('collision-height').value, + offset_x: document.getElementById('collision-offset-x').value, + offset_y: document.getElementById('collision-offset-y').value, + }), + }); +} + // "Commandes" (voir static/js/play/personnage-controller.js) : touches de // déplacement/interaction + blocage d'axe + touches supplémentaires d'un // personnage — un seul formulaire, enregistré en AJAX à chaque diff --git a/static/style.css b/static/style.css index 97000ff7..ae9f3351 100644 --- a/static/style.css +++ b/static/style.css @@ -613,12 +613,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .sceneObjectSprite{ object-fit:contain; } /* Objet de scène kind="fond" (image de fond, voir add_scene_object.py) : - insensible au clic (peut couvrir tout le "monde", ne doit jamais - intercepter un clic destiné à un objet posé par-dessus) et étirée pour - remplir exactement sa boîte (jamais "contain"/lettrboxée comme - .sceneObjectSprite ci-dessus : un fond redimensionné doit combler tout - l'espace, pas laisser des bandes vides). */ -.sceneBackground{ pointer-events:none; object-fit:fill; } + étirée pour remplir exactement sa boîte (jamais "contain"/lettrboxée + comme .sceneObjectSprite ci-dessus : un fond redimensionné doit combler + tout l'espace, pas laisser des bandes vides). Insensible au clic + SEULEMENT au jeu (.playScreen .sceneBackground, peut couvrir tout le + "monde", ne doit jamais intercepter un clic destiné à un objet posé + par-dessus) — JAMAIS dans l'éditeur (.canvasElement, scene_edit.html) : + sans arborescence "Objets de cette scène" pour le sélectionner + autrement, un fond y resterait impossible à re-sélectionner/déplacer + au clic si on le rendait, lui aussi, insensible au clic. */ +.sceneBackground{ object-fit:fill; } +.playScreen .sceneBackground{ pointer-events:none; } /* Caméra qui suit le personnage (voir static/js/play/personnage- controller.js) : .playScreen.playScene est désormais le VIEWPORT (taille diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 1b215b8a..2ed785a8 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -45,20 +45,10 @@ ▶️ Jouer depuis le début
-

Objets de cette scène

- {% if objects %} - - {% else %} -

Aucun objet pour l'instant.

- {% endif %} + +
+ 🧱 Collision +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Taille/décalage réglés par rapport au centre de l'objet — laisse la taille par défaut (celle de l'objet) si son image n'a pas de marge inutile.

+
+
+ {% if obj.kind == 'personnage' %}
🎭 Personnage
+ +
+
+
+ +
+
+

Personnage Forge — clique un autre pour en changer.

{% for slug_c, character in sprite_library_families %} diff --git a/tests/test_collision_settings.py b/tests/test_collision_settings.py new file mode 100644 index 00000000..c15e98b0 --- /dev/null +++ b/tests/test_collision_settings.py @@ -0,0 +1,107 @@ +"""Boîte de collision d'un objet de scène (voir +screens/rendering/collision_settings.py, static/js/play/conditions.js:: +elementsOverlap pour son usage au jeu, testé côté client dans +static/js/play/__tests__/conditions.test.js) : posée AUTOMATIQUEMENT sur +CHAQUE objet (= sa propre boîte, tant que rien n'est réglé), activable/ +désactivable, taille/forme/décalage configurables.""" +import re + +import screens + + +def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision"): + resp = client.post("/games/new", data={"name": name}, follow_redirects=False) + slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + return slug, screen_id + + +def _add_decor(client, slug, screen_id): + resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False) + assert resp.status_code == 302, resp.data + return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + +def test_freshly_placed_object_defaults_to_its_own_box_as_collision(client, tmp_game_slug_cleanup): + """Sans réglage explicite, la boîte de collision EST la boîte de + l'objet (comportement inchangé pour tout objet déjà posé — la + collision se comportait déjà comme ça, implicitement, avant l'ajout + de ce réglage).""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_decor(client, slug, screen_id) + obj = screens.get_scene_object(slug, obj_id) + collision = screens.resolve_collision_settings(obj) + assert collision["enabled"] is True + assert collision["shape"] == "rectangle" + assert collision["width"] == obj["width"] + assert collision["height"] == obj["height"] + assert collision["offset_x"] == 0 and collision["offset_y"] == 0 + + +def test_collision_route_persists_a_custom_box(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_decor(client, slug, screen_id) + + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/collision", + data='{"enabled": true, "shape": "cercle", "width": 30, "height": 30, "offset_x": 5, "offset_y": -5}', + content_type="application/json", + ) + assert resp.status_code == 200 + assert resp.get_json()["collision"] == { + "enabled": True, "shape": "cercle", "width": 30.0, "height": 30.0, "offset_x": 5.0, "offset_y": -5.0, + } + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_collision_settings(obj)["shape"] == "cercle" + + +def test_collision_route_can_disable_the_box_entirely(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_decor(client, slug, screen_id) + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/collision", + data='{"enabled": false}', content_type="application/json", + ) + assert resp.get_json()["collision"]["enabled"] is False + + +def test_collision_route_rejects_an_unknown_shape_and_falls_back_to_rectangle(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_decor(client, slug, screen_id) + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/collision", + data='{"shape": "triangle"}', content_type="application/json", + ) + assert resp.get_json()["collision"]["shape"] == "rectangle" + + +def test_collision_route_ignores_invalid_size_values(client, tmp_game_slug_cleanup): + """Une largeur/hauteur invalide (texte, vide) ne plante pas — retombe + sur la taille de l'objet, comme si rien n'avait été réglé.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_decor(client, slug, screen_id) + resp = client.post( + f"/game/{slug}/scene-objects/{obj_id}/collision", + data='{"width": "pas-un-nombre", "height": ""}', content_type="application/json", + ) + obj = screens.get_scene_object(slug, obj_id) + collision = resp.get_json()["collision"] + assert collision["width"] == obj["width"] + assert collision["height"] == obj["height"] + + +def test_collision_route_404s_for_missing_object(client, game): + resp = client.post(f"/game/{game}/scene-objects/999999/collision", data="{}", content_type="application/json") + assert resp.status_code == 404 + + +def test_full_game_payload_exposes_collision_settings_for_every_scene_object_kind(client, tmp_game_slug_cleanup): + """Pas seulement "personnage" — un décor/fond peut aussi servir + d'obstacle à une condition de collision.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_decor(client, slug, screen_id) + + payload = screens.full_game_payload(slug) + scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id) + obj_payload = next(o for o in scene_screen["elements"] if o["id"] == obj_id) + assert obj_payload["collision"]["enabled"] is True diff --git a/tests/test_personnage_role.py b/tests/test_personnage_role.py new file mode 100644 index 00000000..f0aea3f5 --- /dev/null +++ b/tests/test_personnage_role.py @@ -0,0 +1,65 @@ +"""Rôle d'un personnage posé sur une scène 2D (joueur/ennemie/pnj, voir +screens/rendering/personnage_role.py) : seul un personnage "joueur" est +déplacé/animé automatiquement au clavier et suivi par la caméra (voir +static/js/play/personnage-controller.js, testé côté client dans +static/js/play/__tests__/personnage-controller.test.js).""" +import re + +import screens + + +def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_role"): + resp = client.post("/games/new", data={"name": name}, follow_redirects=False) + slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + return slug, screen_id + + +def _add_personnage(client, slug, screen_id): + resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) + assert resp.status_code == 302, resp.data + return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + +def test_freshly_placed_personnage_defaults_to_the_joueur_role(client, tmp_game_slug_cleanup): + """Comportement inchangé pour un personnage déjà posé avant l'ajout de + ce réglage : c'était déjà, de fait, le seul rôle qui existait.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id) + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_personnage_role(obj) == "joueur" + + +def test_role_route_persists_a_valid_role(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id) + + resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "pnj"}', content_type="application/json") + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True, "role": "pnj"} + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_personnage_role(obj) == "pnj" + + +def test_role_route_rejects_an_unknown_role(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id) + + resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "boss-final"}', content_type="application/json") + assert resp.get_json()["role"] == "joueur" # retombe sur le défaut plutôt que d'enregistrer n'importe quoi + + +def test_role_route_404s_for_missing_object(client, game): + resp = client.post(f"/game/{game}/scene-objects/999999/role", data='{"role": "ennemie"}', content_type="application/json") + assert resp.status_code == 404 + + +def test_full_game_payload_exposes_the_resolved_role_for_personnage_objects(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = _add_personnage(client, slug, screen_id) + client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json") + + payload = screens.full_game_payload(slug) + scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id) + obj_payload = next(o for o in scene_screen["elements"] if o["id"] == obj_id) + assert obj_payload["personnage_role"] == "ennemie" From a231bcf2fabcd2cd1dfd464dad01fd786fd52822 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 15:48:08 +0200 Subject: [PATCH 15/22] =?UTF-8?q?Aper=C3=A7u=20de=20la=20bo=C3=AEte=20de?= =?UTF-8?q?=20collision=20dans=20l'=C3=A9diteur=20+=20blocage=20physique?= =?UTF-8?q?=20au=20jeu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux retours utilisateur sur la boîte de collision (ajoutée précédemment) : - Visible et réglable directement dans l'éditeur de scène : pour l'objet sélectionné, un contour pointillé (rectangle/cercle) se superpose sur le canevas — glisser son corps ajuste le décalage, glisser sa poignée (coin bas-droit) ajuste la taille, les deux se répercutent dans le panneau "🧱 Collision" et inversement (édition des champs -> aperçu à jour). static/js/scenes/scene-editor.js::onCollisionBoxMouseDown/ onCollisionBoxResizeMouseDown, mirror des poignées de position/taille déjà existantes pour l'objet lui-même. - Bloque désormais RÉELLEMENT le déplacement au clavier : avant chaque pas, personnage-controller.js teste si la position candidate chevaucherait la boîte de collision d'un autre objet solide (jamais un "fond", jamais un objet à collision désactivée) et annule ce pas — par AXE séparément, pour permettre de glisser le long d'un mur en diagonale plutôt qu'un blocage total au moindre contact. Réutilise forgeShapesOverlap (conditions.js, factorisé depuis elementsOverlap pour ne jamais dupliquer la règle "qu'est-ce qui se touche"). Co-Authored-By: Claude Sonnet 5 --- static/js/play/__tests__/conditions.test.js | 21 +++- .../__tests__/personnage-controller.test.js | 68 +++++++++++- static/js/play/conditions.js | 21 +++- static/js/play/personnage-controller.js | 88 +++++++++++++-- static/js/scenes/scene-editor.js | 105 ++++++++++++++++-- static/style.css | 15 +++ templates/scene_edit.html | 12 ++ tests/test_collision_overlay_markup.py | 62 +++++++++++ 8 files changed, 365 insertions(+), 27 deletions(-) create mode 100644 tests/test_collision_overlay_markup.py diff --git a/static/js/play/__tests__/conditions.test.js b/static/js/play/__tests__/conditions.test.js index 32edf2a1..aa4be5fc 100644 --- a/static/js/play/__tests__/conditions.test.js +++ b/static/js/play/__tests__/conditions.test.js @@ -16,6 +16,7 @@ const { forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, + forgeShapesOverlap, } = require('../conditions.js'); function withGameData(data, fn) { @@ -127,19 +128,19 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', // forme, activée/désactivée). test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => { const box = { left: 10, top: 20, width: 40, height: 60 }; - assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60 }); + assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle' }); }); test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => { const box = { left: 0, top: 0, width: 100, height: 100 }; const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 }); - assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20 }); + assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle' }); }); test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => { const box = { left: 0, top: 0, width: 100, height: 100 }; const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 }); - assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20 }); + assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle' }); }); test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => { @@ -154,3 +155,17 @@ test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => { assert.equal(forgeCirclesOverlap(a, touching), true); assert.equal(forgeCirclesOverlap(a, apart), false); }); + +test('forgeShapesOverlap — deux cercles utilisent le test cercle-cercle', () => { + const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle' }; + const b = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle' }; + const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle' }; + assert.equal(forgeShapesOverlap(a, b), true); + assert.equal(forgeShapesOverlap(a, apart), false); +}); + +test('forgeShapesOverlap — une paire rectangle/cercle retombe sur le test rectangle-rectangle', () => { + const rect = { left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle' }; + const circle = { left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle' }; + assert.equal(forgeShapesOverlap(rect, circle), true); // les rectangles englobants se chevauchent +}); diff --git a/static/js/play/__tests__/personnage-controller.test.js b/static/js/play/__tests__/personnage-controller.test.js index fb12a137..e24ca042 100644 --- a/static/js/play/__tests__/personnage-controller.test.js +++ b/static/js/play/__tests__/personnage-controller.test.js @@ -16,7 +16,7 @@ Object.assign(global, actionsMod); const { forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, - forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, + forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, forgeWouldCollide, } = require('../personnage-controller.js'); function fakeSceneEl() { @@ -236,3 +236,69 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b assert.equal(worldEl.style.transform, 'translate(-3340px, 0px)'); } finally { global.document = previousDoc; } }); + +// Blocage physique du déplacement en cas de collision — demandé par +// l'utilisateur : "elle doit empêcher d'avancer en cas de collision". +// forgeWouldCollide lit la position des AUTRES objets directement sur le +// DOM (jamais screenData.elements, qui ne bouge jamais après le premier +// chargement) — un test document mock dédié, avec plusieurs éléments. +function withScene(objectsById, screenData, fn) { + const previousDoc = global.document; + global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } }; + global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }]; + global.window.currentScreenId = 1; + try { fn(); } finally { global.document = previousDoc; } +} + +test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approche', () => { + const player = { id: 20, kind: 'personnage' }; + const wallEl = { style: { left: '140px', top: '100px', width: '20px', height: '20px' } }; + const screenData = { elements: [player, { id: 21, kind: 'decor' }] }; + withScene({ 21: wallEl }, screenData, () => { + // Le joueur (20x20) tente d'aller en (130,100) -> chevauche le mur (140..160, 100..120). + assert.equal(forgeWouldCollide(screenData, player, 130, 100, 20, 20), true); + // Assez loin : aucun chevauchement. + assert.equal(forgeWouldCollide(screenData, player, 0, 0, 20, 20), false); + }); +}); + +test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor à collision désactivée', () => { + const player = { id: 22, kind: 'personnage' }; + const bgEl = { style: { left: '0px', top: '0px', width: '3840px', height: '2160px' } }; + const disabledEl = { style: { left: '130px', top: '100px', width: '20px', height: '20px' } }; + const screenData = { + elements: [ + player, + { id: 23, kind: 'fond' }, + { id: 24, kind: 'decor', collision: { enabled: false } }, + ], + }; + withScene({ 23: bgEl, 24: disabledEl }, screenData, () => { + assert.equal(forgeWouldCollide(screenData, player, 130, 100, 20, 20), false); + }); +}); + +test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais dans le mur)', () => { + const player = { id: 25, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const wall = { id: 26, kind: 'decor' }; + const playerEl = fakeSceneEl(); // left/top 100px, 20x20 + const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; + withPersonnageObject(player, { 25: playerEl, 26: wallEl }, () => { + global.heldKeys = new Set(['d']); // vers la droite, vers le mur juste à côté + forgeRunPersonnageControllerTick({ elements: [player, wall] }); + assert.equal(playerEl.style.left, '100px'); // bloqué, jamais déplacé dans le mur + }); +}); + +test('tick — glisse le long d\'un mur en diagonale (un seul axe bloqué)', () => { + const player = { id: 27, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const wall = { id: 28, kind: 'decor' }; + const playerEl = fakeSceneEl(); // left/top 100px, 20x20 + const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; // bloque seulement à droite + withPersonnageObject(player, { 27: playerEl, 28: wallEl }, () => { + global.heldKeys = new Set(['d', 's']); // droite (bloquée) + bas (libre) + forgeRunPersonnageControllerTick({ elements: [player, wall] }); + assert.equal(playerEl.style.left, '100px'); // bloqué + assert.equal(playerEl.style.top, '104px'); // continue de descendre + }); +}); diff --git a/static/js/play/conditions.js b/static/js/play/conditions.js index bbeee776..c33ed97c 100644 --- a/static/js/play/conditions.js +++ b/static/js/play/conditions.js @@ -117,7 +117,8 @@ function forgeCollisionRectFromBox(box, collision) { const offsetY = (collision && collision.offset_y) || 0; const left = box.left + (box.width - width) / 2 + offsetX; const top = box.top + (box.height - height) / 2 + offsetY; - return { left, top, right: left + width, bottom: top + height, width, height }; + const shape = (collision && collision.shape) || 'rectangle'; + return { left, top, right: left + width, bottom: top + height, width, height, shape }; } function forgeRectanglesOverlap(a, b) { @@ -135,6 +136,17 @@ function forgeCirclesOverlap(a, b) { return Math.sqrt(dx * dx + dy * dy) < (ar + br); } +// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX +// boîtes (voir forgeCollisionRectFromBox, qui pose `shape` dessus) — +// réutilisée aussi bien par elementsOverlap() ci-dessous (condition de +// flow) que par forgeWouldCollide() (personnage-controller.js, blocage +// physique du déplacement au clavier) : UNE seule règle de "qu'est-ce qui +// se touche", jamais deux implémentations qui pourraient diverger. +function forgeShapesOverlap(a, b) { + if (a.shape === 'cercle' && b.shape === 'cercle') return forgeCirclesOverlap(a, b); + return forgeRectanglesOverlap(a, b); +} + // Phase 5 — condition de collision : chevauchement des BOÎTES DE // COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments, // positionnées sur leur rectangle RÉEL à l'écran (getBoundingClientRect — @@ -159,10 +171,7 @@ function elementsOverlap(elementIdA, elementIdB) { } const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision); const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision); - const shapeA = (objA && objA.collision && objA.collision.shape) || 'rectangle'; - const shapeB = (objB && objB.collision && objB.collision.shape) || 'rectangle'; - if (shapeA === 'cercle' && shapeB === 'cercle') return forgeCirclesOverlap(rectA, rectB); - return forgeRectanglesOverlap(rectA, rectB); + return forgeShapesOverlap(rectA, rectB); } function evaluateConditionClause(clause) { @@ -207,6 +216,6 @@ function evaluateConditionNode(node) { if (typeof module !== 'undefined' && module.exports) { module.exports = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode, - forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, + forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, forgeShapesOverlap, }; } diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 23808699..33e2746a 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -97,6 +97,51 @@ function forgeStopPersonnageControllers() { } } +// Boîte de collision d'un objet à une position DONNÉE (pas forcément sa +// position actuelle — voir forgeWouldCollide ci-dessous, qui teste une +// position candidate AVANT de l'appliquer) — réutilise forgeCollisionRectFromBox +// (conditions.js, déjà chargé avant ce fichier dans templates/play.html), +// la MÊME formule que la condition de collision de flow (elementsOverlap). +function forgeObjectCollisionRectAt(left, top, width, height, collision) { + return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision); +} + +// Tout objet de scène potentiellement "solide" pour le déplacement au +// clavier — jamais un "fond" (image de décor, voir add_scene_object.py : +// pointer-events déjà désactivés au jeu, jamais un obstacle), ni un +// objet dont la collision est explicitement désactivée, ni l'objet qui +// 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); + }); +} + +// Est-ce que le personnage, À la position candidate (newLeft/newTop), +// chevaucherait la boîte de collision d'un AUTRE objet solide ? Bloque +// alors le déplacement (voir forgeRunPersonnageControllerTick) — demandé +// par l'utilisateur : "elle doit empêcher d'avancer en cas de collision". +// Lit la position ACTUELLE des autres objets sur le DOM (pas +// screenData.elements, jamais mis à jour après un premier déplacement — +// voir actions.js, qui ne touche que le style, jamais les données) pour +// rester correct même si un autre objet a déjà bougé. +function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) { + const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision); + const obstacles = forgeSolidObstacles(screenData, movingObj.id); + for (let i = 0; i < obstacles.length; i++) { + const other = obstacles[i]; + const otherEl = document.querySelector('[data-object-id="' + other.id + '"]'); + if (!otherEl) continue; + const otherLeft = parseFloat(otherEl.style.left) || 0; + const otherTop = parseFloat(otherEl.style.top) || 0; + const otherWidth = parseFloat(otherEl.style.width) || 0; + const otherHeight = parseFloat(otherEl.style.height) || 0; + const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision); + if (forgeShapesOverlap(movingRect, otherRect)) return true; + } + return false; +} + // 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers // (triggers.js), pour un déplacement perçu comme continu. function forgeRunPersonnageControllerTick(screenData) { @@ -110,16 +155,37 @@ function forgeRunPersonnageControllerTick(screenData) { var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); if (!targetEl) return; - var dx = 0, dy = 0; + var rawDx = 0, rawDy = 0; if (cmd.axe !== 'vertical') { - if (heldKeys.has(cmd.gauche)) dx -= cmd.vitesse; - if (heldKeys.has(cmd.droite)) dx += cmd.vitesse; + if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse; + if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse; } if (cmd.axe !== 'horizontal') { - if (heldKeys.has(cmd.haut)) dy -= cmd.vitesse; - if (heldKeys.has(cmd.bas)) dy += cmd.vitesse; + if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse; + if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse; } + // Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un + // seul est gêné) — glisser le long d'un mur en diagonale reste + // possible, plus naturel qu'un blocage total au moindre contact. + var curLeft = parseFloat(targetEl.style.left) || 0; + var curTop = parseFloat(targetEl.style.top) || 0; + var boxWidth = parseFloat(targetEl.style.width) || 0; + var boxHeight = parseFloat(targetEl.style.height) || 0; + var dx = rawDx, dy = rawDy; + if (dx !== 0) { + var candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx); + if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0; + } + if (dy !== 0) { + var candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy); + if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0; + } + // Se tourne vers la direction TENTÉE même si le déplacement est + // finalement bloqué (fait face au mur/obstacle, plus naturel qu'un + // personnage qui reste dos tourné contre ce qui le bloque). + if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite'); + // Même fps pour idle et walk (bug signalé par l'utilisateur : idle // tournait à 4 i/s contre 8 pour walk, perçue comme "les autres // animations sont lentes à côté de la marche" — aucune raison réelle @@ -129,10 +195,7 @@ function forgeRunPersonnageControllerTick(screenData) { if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true })); return; } - if (dx !== 0) { - applyObjectProperty(targetEl, 'orientation', dx < 0 ? 'gauche' : 'droite'); - applyObjectProperty(targetEl, 'pos_x_relatif', dx); - } + if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx); if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy); var moveAnim = forgePersonnageMovementAnim(obj.id); if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true })); @@ -195,5 +258,12 @@ if (typeof module !== 'undefined' && module.exports) { forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers, forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, + forgeObjectCollisionRectAt, forgeSolidObstacles, forgeWouldCollide, }; + // node:test charge ce fichier isolément (voir __tests__/) — jamais un + // - - - + {% endblock %} diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 24e29be0..e9110355 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -1305,6 +1305,7 @@ + From ee270d02863219f45d5f370bff57de19c3043eae Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 17:11:42 +0200 Subject: [PATCH 17/22] =?UTF-8?q?=C3=89diteur=20de=20collision=20(jeu=202D?= =?UTF-8?q?)=20:=20r=C3=A8gles=20trigger=20->=20action=20par=20objet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvel onglet "🧩 Collision" dans l'éditeur de scène 2D : chaque objet (hors joueur et fond) peut porter des règles "à la collision" ou "dans un périmètre (px)" déclenchant une action (quête, attaque, événement, ou interagir - qui affiche "Appuie sur [touche]" puis exécute une sous-action à l'appui, un seul niveau d'imbrication). Backend (sanitisation, route de persistance, exposition dans full_game_payload) + assistant modal en cartes empilées côté client. Ajoute le moteur d'exécution runtime (collision-rules-controller.js) : détecte l'entrée en collision/périmètre avec le joueur à chaque tick, déclenche l'action une seule fois par entrée, pur JS client sans appel serveur (fonctionne à l'identique en ligne et en export SCORM). Co-Authored-By: Claude Sonnet 5 --- routes/scenes/__init__.py | 1 + routes/scenes/scene_edit_view.py | 26 ++ routes/scenes/scene_object_collision_rules.py | 21 ++ screens/__init__.py | 5 + screens/payload/full_game_payload.py | 10 + screens/rendering/collision_rules.py | 104 ++++++++ .../set_scene_object_collision_rules.py | 23 ++ .../collision-rules-controller.test.js | 135 ++++++++++ static/js/play/collision-rules-controller.js | 188 ++++++++++++++ static/js/play/screens.js | 4 + static/js/scenes/collision-rules-editor.js | 242 ++++++++++++++++++ static/style.css | 31 +++ templates/play.html | 1 + templates/scene_edit.html | 41 +++ tests/test_collision_rules.py | 157 ++++++++++++ 15 files changed, 989 insertions(+) create mode 100644 routes/scenes/scene_object_collision_rules.py create mode 100644 screens/rendering/collision_rules.py create mode 100644 screens/scenes/set_scene_object_collision_rules.py create mode 100644 static/js/play/__tests__/collision-rules-controller.test.js create mode 100644 static/js/play/collision-rules-controller.js create mode 100644 static/js/scenes/collision-rules-editor.js create mode 100644 tests/test_collision_rules.py diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index 85fc3d14..8531ff69 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -6,4 +6,5 @@ from . import ( scene_object_commands, scene_object_role, scene_object_collision, + scene_object_collision_rules, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index ff3dcef0..c8fb5c11 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -43,6 +43,29 @@ 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"] == "fond": + 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), + }) + 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) @@ -102,4 +125,7 @@ def render_scene_edit(slug, screen_id, game): definitions=db.list_definitions(slug), definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}), definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}), + collision_rule_objects_json=json.dumps(collision_rule_objects), + collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, + collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, ) diff --git a/routes/scenes/scene_object_collision_rules.py b/routes/scenes/scene_object_collision_rules.py new file mode 100644 index 00000000..d7c44159 --- /dev/null +++ b/routes/scenes/scene_object_collision_rules.py @@ -0,0 +1,21 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//collision-rules", methods=["POST"]) +def scene_object_collision_rules(slug, object_id): + """Liste complète des règles "déclencheur -> action" d'un objet (voir + "🧩 Collision" dans templates/scene_edit.html) — le client poste + toujours la liste ENTIÈRE (ajout/suppression déjà appliqués côté + client), jamais une règle isolée, même patron que + scene_object_commands.py pour "extra".""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + rules = screens.sanitize_collision_rules(body.get("rules")) + screens.set_scene_object_collision_rules(slug, object_id, rules) + return jsonify({"ok": True, "rules": rules}) diff --git a/screens/__init__.py b/screens/__init__.py index 5638a527..169c69c6 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -83,6 +83,10 @@ 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 from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES +from .rendering.collision_rules import ( + resolve_collision_rules, sanitize_collision_rules, + ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX, +) from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .screens_repo.ensure_schema import ensure_schema @@ -120,6 +124,7 @@ from .scenes.set_scene_object_personnage_data import set_scene_object_personnage 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.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 8fb6464a..14930655 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -11,6 +11,7 @@ from ..rendering.personnage_data import resolve_personnage_animations 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 ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -61,6 +62,15 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # 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) diff --git a/screens/rendering/collision_rules.py b/screens/rendering/collision_rules.py new file mode 100644 index 00000000..738e2ebc --- /dev/null +++ b/screens/rendering/collision_rules.py @@ -0,0 +1,104 @@ +import json + +# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir +# templates/scene_edit.html) : chaque objet de scène (hors le personnage +# "joueur" — voir screens/rendering/personnage_role.py — et un "fond", +# jamais une cible/source de collision) porte une LISTE de règles +# "déclencheur -> action", construites carte par carte dans l'éditeur. +# +# Déclencheur : "collision" (chevauchement direct, voir +# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le +# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). +# +# Action (une seule par règle) : +# - "quete" : quete_id (référence libre — l'éditeur de quête lui-même +# reste à construire, cette action ne fait QUE mémoriser +# la référence pour l'instant). +# - "attaque" : joue la pose "attack" du personnage si disponible (voir +# personnage-controller.js) — pas de système de combat +# (dégâts/PV...) pour l'instant, effet visuel seul. +# - "evenement" : evenement_id — réutilise TEL QUEL le système +# d'événements personnalisés existant (db.list_custom_events, +# window.dispatchGameEvent), la logique de flow d'un écran +# document peut donc déjà écouter ce déclenchement. +# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du +# joueur, voir personnage_commands.py) tant que la +# collision/le périmètre est actif, puis exécute +# `sub_action` (même forme qu'une action, SAUF +# "interagir" à nouveau — UN seul niveau d'imbrication, +# pas une récursion illimitée) à l'appui de la touche. +ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"] +TRIGGER_TYPES = ["collision", "perimetre"] +DEFAULT_PERIMETRE_PX = 5 +# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une +# structure sans fin construite par un JSON posté à la main. +_MAX_ACTION_DEPTH = 2 + + +def _sanitize_action(raw_action, depth=1): + if not isinstance(raw_action, dict): + return None + action_type = raw_action.get("type") + if action_type not in ACTION_TYPES: + return None + if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH: + return None # un seul niveau d'imbrication autorisé + action = {"type": action_type} + if action_type == "quete": + quete_id = raw_action.get("quete_id") + if not (isinstance(quete_id, str) and quete_id.strip()): + return None + action["quete_id"] = quete_id.strip() + elif action_type == "evenement": + try: + action["evenement_id"] = int(raw_action.get("evenement_id")) + except (TypeError, ValueError): + return None + elif action_type == "interagir": + sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1) + if not sub_action: + return None + action["sub_action"] = sub_action + # "attaque" : aucun champ supplémentaire. + return action + + +def sanitize_collision_rules(raw_rules): + """Valide/nettoie une liste de règles postée par le client (voir + routes/scenes/scene_object_collision_rules.py) — une règle invalide + (déclencheur/action inconnus, imbrication trop profonde...) est + IGNORÉE plutôt que de faire planter la sauvegarde entière.""" + if not isinstance(raw_rules, list): + return [] + cleaned = [] + for raw_rule in raw_rules[:50]: # marge large, jamais illimité + if not isinstance(raw_rule, dict): + continue + trigger = raw_rule.get("trigger") + if trigger not in TRIGGER_TYPES: + continue + action = _sanitize_action(raw_rule.get("action")) + if not action: + continue + rule = {"trigger": trigger, "action": action} + if trigger == "perimetre": + try: + rule["perimetre_px"] = max(1, float(raw_rule.get("perimetre_px", DEFAULT_PERIMETRE_PX))) + except (TypeError, ValueError): + rule["perimetre_px"] = DEFAULT_PERIMETRE_PX + cleaned.append(rule) + return cleaned + + +def resolve_collision_rules(obj): + """Décode attributes._collision_rules en liste Python, jamais None — + un attribut absent/corrompu retombe sur aucune règle (comportement + inchangé pour tout objet déjà posé avant l'ajout de cet éditeur).""" + raw = (obj.get("attributes") or {}).get("_collision_rules") + if not raw: + return [] + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return [] + return sanitize_collision_rules(parsed) diff --git a/screens/scenes/set_scene_object_collision_rules.py b/screens/scenes/set_scene_object_collision_rules.py new file mode 100644 index 00000000..2c2f08f1 --- /dev/null +++ b/screens/scenes/set_scene_object_collision_rules.py @@ -0,0 +1,23 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_collision_rules(slug, object_id, rules): + """Enregistre la liste de règles "déclencheur -> action" d'un objet de + scène (voir screens/rendering/collision_rules.py, "🧩 Collision" dans + templates/scene_edit.html) — un attribut JSON distinct de tous les + autres réglages d'objet.""" + 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 + attributes = json.loads(row["attributes"] or "{}") + attributes["_collision_rules"] = json.dumps(rules) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), 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 new file mode 100644 index 00000000..4ec3f1a2 --- /dev/null +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -0,0 +1,135 @@ +// Moteur d'exécution des règles "🧩 Collision" (voir +// static/js/play/collision-rules-controller.js) : déclenche une action une +// seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que +// la condition reste vraie — et gère "interagir" à part (bulle + attente +// de touche). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +global.gameData = global.gameData || {}; +global.screensData = []; +global.heldKeys = new Set(); + +const actionsMod = require('../actions.js'); +Object.assign(global, actionsMod); +require('../personnage-controller.js'); + +const { + forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, + forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers, +} = require('../collision-rules-controller.js'); + +function fakeEl() { + const classes = new Set(); + const children = []; + 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; }, + }; +} + +function fakeBubbleEl() { + return { className: 'collisionInteractBubble', style: {}, textContent: '' }; +} + +function fakeDocument(elByObjectId) { + return { + querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; }, + createElement: () => fakeBubbleEl(), + }; +} + +test.afterEach(() => { forgeStopCollisionRuleControllers(); }); + +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', () => { + const objRect = rect(0, 0, 20, 20); + const overlapping = rect(10, 10, 20, 20); + const far = rect(200, 200, 20, 20); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, overlapping), true); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, far), false); +}); + +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 + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true); + assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false); +}); + +test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => { + const calls = []; + global.window.dispatchGameEvent = id => calls.push(id); + forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 }); + 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('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => { + global.window.dispatchGameEvent = () => {}; + const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } }; + const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }; + const playerEl = fakeEl(); + const enemyEl = fakeEl(); + enemyEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20) + + const previousDoc = global.document; + global.document = fakeDocument({ 1: playerEl, 2: enemyEl }); + global.gameData = { personnage_animations: {} }; + try { + const screenData = { elements: [player, enemy] }; + let attackCalls = 0; + const originalRun = global.runSpriteAnimation; + global.runSpriteAnimation = () => { attackCalls++; }; + forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche + forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas + global.runSpriteAnimation = originalRun; + // "attaque" ne joue une animation que si le personnage a une pose + // "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0 + // appel réel à runSpriteAnimation, mais le test vérifie surtout + // l'absence de double déclenchement plutôt que l'appel lui-même. + assert.equal(attackCalls, 0); + } finally { global.document = previousDoc; } +}); + +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(); + pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; + const pnjElFar = fakeEl(); + 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 }); + forgeCollisionRuleTick({ elements: [player, pnj] }); + let bubble = pnjElNear.querySelector('.collisionInteractBubble'); + assert.equal(bubble.style.display, 'block'); + assert.equal(bubble.textContent, 'Appuie sur e'); + + global.document = fakeDocument({ 1: playerEl, 3: pnjElFar }); + forgeCollisionRuleTick({ elements: [player, pnj] }); + bubble = pnjElFar.querySelector('.collisionInteractBubble'); + assert.equal(bubble, undefined); // jamais créée pour cet élément-là (jamais actif dessus) + } finally { global.document = previousDoc; } +}); + +test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => { + assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] })); + assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] })); +}); diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js new file mode 100644 index 00000000..277b4b23 --- /dev/null +++ b/static/js/play/collision-rules-controller.js @@ -0,0 +1,188 @@ +// ---------- Éditeur de collision — moteur d'exécution en jeu ---------- +// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le +// personnage "joueur" — voir screens/rendering/collision_rules.py) peut +// porter des règles "déclencheur -> action" posées dans l'onglet +// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier +// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre +// la position du joueur, déclenche l'action une seule fois par entrée +// (pas en continu tant que la condition reste vraie), même cadence que +// personnage-controller.js (50ms) et réutilise ses mêmes briques +// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js). +// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique +// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun +// branchement FORGE_OFFLINE nécessaire ici. + +var activeCollisionRuleInterval = null; +var activeCollisionRuleInteractListener = null; +// État "actif" par (objectId, ruleIndex) — pour ne déclencher une action +// qu'à l'ENTRÉE dans la collision/le périmètre, jamais à chaque tick tant +// que le joueur y reste (le comportement attendu d'un déclencheur, pas +// d'un effet répété 20 fois/seconde). +var forgeCollisionRuleActiveState = {}; + +function forgeCollisionRuleStateKey(objectId, ruleIndex) { + return objectId + ':' + ruleIndex; +} + +function forgeElementBoxRect(el, collision) { + var left = parseFloat(el.style.left) || 0; + var top = parseFloat(el.style.top) || 0; + var width = parseFloat(el.style.width) || 0; + var height = parseFloat(el.style.height) || 0; + 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). +function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { + if (rule.trigger === 'collision') return forgeShapesOverlap(objRect, 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); + return dist <= (rule.perimetre_px || 0); +} + +function forgeCollisionInteractBubbleEl(objectEl) { + var bubble = objectEl.querySelector('.collisionInteractBubble'); + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'collisionInteractBubble'; + objectEl.appendChild(bubble); + } + return bubble; +} + +function forgeShowCollisionInteractBubble(objectEl, key) { + var bubble = forgeCollisionInteractBubbleEl(objectEl); + bubble.textContent = 'Appuie sur ' + key; + bubble.style.display = 'block'; +} + +function forgeHideCollisionInteractBubble(objectEl) { + var bubble = objectEl.querySelector('.collisionInteractBubble'); + if (bubble) bubble.style.display = 'none'; +} + +// Exécute une action (top-level ou sub_action d'un "interagir") — jamais +// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick +// qui gère "interagir" à part (affichage de bulle + attente de touche). +function forgeRunCollisionRuleAction(action, targetEl, obj) { + if (action.type === 'evenement') { + window.dispatchGameEvent(action.evenement_id); + return; + } + if (action.type === 'attaque') { + var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null; + if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false })); + 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); + return; + } +} + +function forgeCollisionRuleTick(screenData) { + var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); + var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]'); + if (!player || !playerEl) return; + var playerRect = forgeElementBoxRect(playerEl, player.collision); + + (screenData.elements || []).forEach(function (obj) { + var rules = obj.collision_rules; + if (!rules || !rules.length) return; + var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); + if (!targetEl) return; + var objRect = forgeElementBoxRect(targetEl, obj.collision); + + var anyInteractActive = false; + rules.forEach(function (rule, ruleIndex) { + var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex); + var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect); + var wasActive = !!forgeCollisionRuleActiveState[stateKey]; + + if (rule.action.type === 'interagir') { + if (isActive) { + anyInteractActive = true; + forgeShowCollisionInteractBubble(targetEl, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E'); + } + forgeCollisionRuleActiveState[stateKey] = isActive; + return; + } + + if (isActive && !wasActive) forgeRunCollisionRuleAction(rule.action, targetEl, obj); + forgeCollisionRuleActiveState[stateKey] = isActive; + }); + if (!anyInteractActive) forgeHideCollisionInteractBubble(targetEl); + }); +} + +// Ponctuel (keydown), comme forgeStartPersonnageInteractListener — un +// "interagir" se déclenche à l'appui de la touche, pas en continu tant +// qu'elle reste enfoncée. +function forgeStartCollisionRuleInteractListener(screenData) { + activeCollisionRuleInteractListener = function (ev) { + var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); + if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return; + (screenData.elements || []).forEach(function (obj) { + var rules = obj.collision_rules; + if (!rules || !rules.length) return; + var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); + if (!targetEl) return; + rules.forEach(function (rule, ruleIndex) { + if (rule.action.type !== 'interagir') return; + var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex); + if (!forgeCollisionRuleActiveState[stateKey]) return; + forgeRunCollisionRuleAction(rule.action.sub_action, targetEl, obj); + }); + }); + }; + window.addEventListener('keydown', activeCollisionRuleInteractListener); +} + +function forgeStopCollisionRuleControllers() { + if (activeCollisionRuleInterval) { + clearInterval(activeCollisionRuleInterval); + activeCollisionRuleInterval = null; + } + if (activeCollisionRuleInteractListener) { + window.removeEventListener('keydown', activeCollisionRuleInteractListener); + activeCollisionRuleInteractListener = null; + } + forgeCollisionRuleActiveState = {}; +} + +// Même patron par écran que forgeStartPersonnageControllers — appelé +// juste après lui (voir static/js/play/screens.js), n'a d'effet que si +// l'écran a un joueur ET au moins un objet avec des règles. +function forgeStartCollisionRuleControllers(screenData) { + forgeStopCollisionRuleControllers(); + if (!screenData || screenData.kind !== 'jeu_2d') return; + var elements = screenData.elements || []; + var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; }); + var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; }); + if (!hasPlayer || !hasRules) return; + activeCollisionRuleInterval = setInterval(function () { forgeCollisionRuleTick(screenData); }, 50); + forgeStartCollisionRuleInteractListener(screenData); +} + +// static/js/play/__tests__/ (node:test) — même garde que personnage-controller.js. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick, + forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers, + forgeCollisionRuleStateKey, forgeElementBoxRect, + }; + const conditionsMod = require('./conditions.js'); + global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox; + global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap; + const personnageMod = require('./personnage-controller.js'); + global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists; + global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8; +} diff --git a/static/js/play/screens.js b/static/js/play/screens.js index d6c8880b..d7946cab 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -53,6 +53,10 @@ function showScreen(id) { // écran (voir static/js/play/personnage-controller.js) — n'a d'effet // que sur un écran "jeu_2d" contenant au moins un objet kind="personnage". forgeStartPersonnageControllers(screensData.find(function(s) { return s.id === id; })); + // Règles "à la collision/dans un périmètre -> action" (voir + // 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; })); } // Phase 6 — musique de fond par écran (voir screen-bg-music-url dans diff --git a/static/js/scenes/collision-rules-editor.js b/static/js/scenes/collision-rules-editor.js new file mode 100644 index 00000000..9e1be956 --- /dev/null +++ b/static/js/scenes/collision-rules-editor.js @@ -0,0 +1,242 @@ +// ---------- Éditeur de collision (nouvel onglet "🧩 Collision") ---------- +// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage +// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/ +// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà +// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais +// un vrai formulaire à plusieurs champs) : un clic choisit une option, la +// carte suivante s'emboîte aussitôt. Voir screens/rendering/ +// collision_rules.py pour la forme exacte d'une règle (côté serveur, où +// elle est validée une seconde fois avant d'être enregistrée). + +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', +}; +var COLLISION_TRIGGER_LABELS = { collision: '💥 À la collision', perimetre: '📍 Dans un périmètre' }; + +function collisionRuleObjectLabel(obj) { + var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor'; + return (obj.name || kindLabel); +} + +// Un maillon = un segment de la "chaîne de pièces" affichée dans la carte +// (déclencheur, puis action, puis éventuellement sous-action) — texte +// seul, la logique de lecture vit dans collisionRuleActionSummary/ +// collisionRuleTriggerSummary ci-dessous. +function collisionRuleTriggerSummary(rule) { + if (rule.trigger === 'perimetre') return COLLISION_TRIGGER_LABELS.perimetre + ' (' + rule.perimetre_px + 'px)'; + return COLLISION_TRIGGER_LABELS.collision; +} + +function collisionRuleActionSummary(action) { + if (action.type === 'quete') return COLLISION_ACTION_LABELS.quete + ' « ' + action.quete_id + ' »'; + if (action.type === 'evenement') { + var ev = CUSTOM_EVENTS_MAP[action.evenement_id]; + return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »'; + } + return COLLISION_ACTION_LABELS[action.type] || action.type; +} + +// Une règle = une chaîne de "maillons" (déclencheur -> action -> sous-action +// éventuelle) — chacun sa propre petite carte, visuellement emboîtées. +function collisionRuleChainHtml(rule, ruleIndex, objectId) { + var links = [collisionRuleTriggerSummary(rule), collisionRuleActionSummary(rule.action)]; + var action = rule.action; + while (action.type === 'interagir' && action.sub_action) { + links.push(collisionRuleActionSummary(action.sub_action)); + action = action.sub_action; + } + var linksHtml = links.map(function (text) { + return '' + text.replace(/'; + }).join('→'); + return ( + '
' + + '' + + '' + + '
' + ); +} + +function collisionRuleCardHtml(obj) { + var rulesHtml = obj.rules.length + ? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('') + : '

Aucune règle pour l\'instant.

'; + return ( + '
' + + '
' + + '
' + obj.thumbnail_html + '
' + + '

' + collisionRuleObjectLabel(obj).replace(/' + + '

' + + '
' + rulesHtml + '
' + + '' + + '
' + ); +} + +function renderCollisionRuleCards() { + var container = document.getElementById('collisionRulesCards'); + if (!container) return; + if (!COLLISION_RULE_OBJECTS.length) { + container.innerHTML = '

Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).

'; + return; + } + container.innerHTML = COLLISION_RULE_OBJECTS.map(collisionRuleCardHtml).join(''); +} + +function collisionRuleObjectById(objectId) { + return COLLISION_RULE_OBJECTS.find(function (o) { return o.id === objectId; }); +} + +// Enregistre la liste COMPLÈTE des règles de cet objet (voir +// routes/scenes/scene_object_collision_rules.py — toujours la liste +// entière, jamais une règle isolée) puis redessine SA carte. +function saveCollisionRules(objectId) { + var obj = collisionRuleObjectById(objectId); + if (!obj) return; + fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision-rules', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rules: obj.rules }), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + if (json.rules) obj.rules = json.rules; // reflète la validation serveur (voir sanitize_collision_rules) + renderCollisionRuleCards(); + }); +} + +function deleteCollisionRule(objectId, ruleIndex) { + var obj = collisionRuleObjectById(objectId); + if (!obj) return; + obj.rules.splice(ruleIndex, 1); + saveCollisionRules(objectId); +} + +// ---------- Assistant "+ Action" ---------- +// État du parcours en cours — un seul à la fois (la modale n'autorise +// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi, +// et l'action en cours de construction (posée directement dans +// `action`, ou dans `action.sub_action` au second niveau pour "interagir"). +var _collisionWizard = null; + +function openCollisionRuleModal(objectId) { + _collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX }; + document.getElementById('collisionRuleModal').classList.add('is-active'); + renderCollisionWizardTriggerStep(); +} + +function closeCollisionRuleModal() { + document.getElementById('collisionRuleModal').classList.remove('is-active'); + _collisionWizard = null; +} + +function collisionWizardBodyEl() { + return document.getElementById('collisionRuleModalBody'); +} + +// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable, +// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX). +function renderCollisionWizardTriggerStep() { + collisionWizardBodyEl().innerHTML = + '

Cette règle se déclenche...

' + + '
' + + '' + + '
' + + '' + + '' + + 'px' + + '
' + + '
'; +} + +function collisionWizardChooseTrigger(trigger) { + _collisionWizard.trigger = trigger; + if (trigger === 'perimetre') { + var pxEl = document.getElementById('collisionWizardPerimetrePx'); + _collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX; + } + renderCollisionWizardActionStep(false); +} + +// Étape 2 (et, pour "interagir", étape 3 en second niveau — `nested` +// distingue les deux, "interagir" n'étant proposé qu'au premier niveau, +// voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) : +// quelle action exécuter. +function renderCollisionWizardActionStep(nested) { + var types = nested ? COLLISION_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_ACTION_TYPES; + var buttons = types.map(function (type) { + return ''; + }).join(''); + collisionWizardBodyEl().innerHTML = + '

' + (nested ? 'Une fois la touche pressée...' : 'Alors...') + '

' + + '
' + buttons + '
'; +} + +function collisionWizardFinalizeAction(action, nested) { + if (nested) { + // Deuxième niveau ("interagir" -> sous-action) : referme la maille + // sur l'action de premier niveau, déjà mémorisée sur le wizard. + _collisionWizard.pendingInteragir.sub_action = action; + collisionWizardSaveRule(_collisionWizard.pendingInteragir); + return; + } + if (action.type === 'interagir') { + // "interagir" n'est jamais final : une carte supplémentaire s'emboîte + // pour choisir CE QUI se passe une fois la touche pressée. + _collisionWizard.pendingInteragir = action; + renderCollisionWizardActionStep(true); + return; + } + collisionWizardSaveRule(action); +} + +function collisionWizardSaveRule(action) { + var rule = { trigger: _collisionWizard.trigger, action: action }; + if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx; + var obj = collisionRuleObjectById(_collisionWizard.objectId); + obj.rules.push(rule); + var objectId = _collisionWizard.objectId; + closeCollisionRuleModal(); + saveCollisionRules(objectId); +} + +// Chaque type d'action demande éventuellement une précision avant de +// pouvoir se finaliser (quête -> son id ; événement -> lequel) — "attaque" +// n'a besoin de rien de plus, se finalise directement. +function collisionWizardChooseAction(type, nested) { + if (type === 'attaque' || type === 'interagir') { + collisionWizardFinalizeAction({ type: type }, nested); + return; + } + if (type === 'quete') { + collisionWizardBodyEl().innerHTML = + '

Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :

' + + '
' + + '
' + + '
' + + '
'; + return; + } + if (type === 'evenement') { + var eventIds = Object.keys(CUSTOM_EVENTS_MAP); + if (!eventIds.length) { + collisionWizardBodyEl().innerHTML = '

Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "📣 Événements", puis reviens ici.

'; + return; + } + var buttons = eventIds.map(function (id) { + return ''; + }).join(''); + collisionWizardBodyEl().innerHTML = '

Quel événement ?

' + buttons + '
'; + } +} + +function collisionWizardSubmitQuete(nested) { + var input = document.getElementById('collisionWizardQueteId'); + var queteId = input && input.value.trim(); + if (!queteId) { input.focus(); return; } + collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested); +} + +document.addEventListener('DOMContentLoaded', function () { + if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards(); +}); diff --git a/static/style.css b/static/style.css index 7397823b..ecc17843 100644 --- a/static/style.css +++ b/static/style.css @@ -669,6 +669,37 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d background:#ff5c5c; border-radius:50%; border:2px solid #1a1d24; } +/* Éditeur de collision (voir "🧩 Collision", templates/scene_edit.html, + static/js/scenes/collision-rules-editor.js) : une carte par objet + éligible, chaque règle déjà posée affichée comme une CHAÎNE de petits + maillons (déclencheur -> action -> sous-action éventuelle) — la + métaphore "pièces qui s'emboîtent" demandée, sans un vrai graphe à + tracer à la souris (l'assistant "+ Action" construit la chaîne pas à + pas, voir openCollisionRuleModal). */ +.collisionRulesCards{ display:flex; flex-direction:column; gap:14px; margin-top:12px; } +.collisionRuleCard{ + background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; +} +.collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:8px; } +.collisionRuleCardThumb{ + width:36px; height:36px; border-radius:6px; overflow:hidden; background:var(--panel2); + display:flex; align-items:center; justify-content:center; flex-shrink:0; +} +.collisionRuleCardThumb img{ max-width:100%; max-height:100%; object-fit:contain; } +.collisionRuleCardTitle{ margin:0; font-size:14px; } +.collisionRuleCardRules{ display:flex; flex-direction:column; gap:8px; margin-bottom:10px; } +.collisionRuleChain{ + display:flex; align-items:center; justify-content:space-between; gap:8px; + background:var(--panel2); border:1px solid var(--border); border-radius:8px; padding:6px 8px; +} +.collisionRuleChainLinks{ display:flex; align-items:center; flex-wrap:wrap; gap:6px; } +.collisionRuleLink{ + background:var(--accent); color:#fff; border-radius:12px; padding:3px 10px; font-size:12px; white-space:nowrap; +} +.collisionRuleArrow{ color:var(--forge-text-muted, #8b95a7); font-size:12px; } +.collisionWizardChoices{ display:flex; flex-direction:column; } +.collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; } + .listRow.selected{ border-color:var(--accent); background:var(--panel2); } .childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); } diff --git a/templates/play.html b/templates/play.html index 474aa735..3c1875ee 100644 --- a/templates/play.html +++ b/templates/play.html @@ -155,6 +155,7 @@ + @@ -410,4 +450,5 @@ + {% endblock %} diff --git a/tests/test_collision_rules.py b/tests/test_collision_rules.py new file mode 100644 index 00000000..1a1e0fae --- /dev/null +++ b/tests/test_collision_rules.py @@ -0,0 +1,157 @@ +"""Éditeur de collision (onglet "🧩 Collision", voir +screens/rendering/collision_rules.py) : une règle "déclencheur (collision/ +périmètre) -> action (quête/attaque/événement/interagir)" par carte +objet — toujours les AUTRES objets qui réagissent à la présence du +joueur, jamais l'inverse. static/js/scenes/collision-rules-editor.js +construit l'assistant "+ Action" ; ce fichier teste la couche serveur +(défauts, validation, exposition dans full_game_payload).""" +import re + +import screens + + +def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_rules"): + resp = client.post("/games/new", data={"name": name}, follow_redirects=False) + slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1]) + screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") + return slug, screen_id + + +def _add_personnage(client, slug, screen_id, role=None): + resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False) + assert resp.status_code == 302, resp.data + obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + if role: + client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json") + return obj_id + + +def _add_custom_event(client, slug, name="NEED_HELP"): + resp = client.post(f"/game/{slug}/events/new", data={"name": name}, follow_redirects=False) + assert resp.status_code == 302, resp.data + events = screens.list_custom_events(slug) if hasattr(screens, "list_custom_events") else __import__("db").list_custom_events(slug) + return next(e["id"] for e in events if e["name"] == name) + + +def test_freshly_placed_object_has_no_collision_rules(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + obj = screens.get_scene_object(slug, enemy_id) + assert screens.resolve_collision_rules(obj) == [] + + +def test_rules_route_persists_a_collision_trigger_attack_action(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + resp = client.post( + f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "attaque"}}]}', + content_type="application/json", + ) + assert resp.status_code == 200 + assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "attaque"}}] + obj = screens.get_scene_object(slug, enemy_id) + assert screens.resolve_collision_rules(obj) == [{"trigger": "collision", "action": {"type": "attaque"}}] + + +def test_rules_route_persists_a_perimeter_trigger_with_custom_radius(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + resp = client.post( + f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", + data='{"rules": [{"trigger": "perimetre", "perimetre_px": 40, "action": {"type": "attaque"}}]}', + content_type="application/json", + ) + rules = resp.get_json()["rules"] + assert rules == [{"trigger": "perimetre", "perimetre_px": 40.0, "action": {"type": "attaque"}}] + + +def test_rules_route_persists_a_quest_action_with_free_form_id(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + pnj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "quete", "quete_id": "quete-001"}}]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [{"trigger": "collision", "action": {"type": "quete", "quete_id": "quete-001"}}] + + +def test_rules_route_persists_an_interagir_action_with_one_level_of_nesting(client, tmp_game_slug_cleanup): + """"interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action + à l'appui — un seul niveau d'imbrication autorisé (voir + screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH).""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + pnj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": "q1"}}}]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [ + {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "quete", "quete_id": "q1"}}}, + ] + + +def test_rules_route_rejects_interagir_nested_inside_interagir(client, tmp_game_slug_cleanup): + """Une imbrication trop profonde (interagir -> interagir -> ...) est + ignorée plutôt que d'accepter une structure sans fin postée à la main.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + pnj_id = _add_personnage(client, slug, screen_id, role="pnj") + + resp = client.post( + f"/game/{slug}/scene-objects/{pnj_id}/collision-rules", + data='{"rules": [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "interagir", "sub_action": {"type": "attaque"}}}}]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [] # la règle entière est rejetée + + +def test_rules_route_drops_malformed_rules(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + resp = client.post( + f"/game/{slug}/scene-objects/{enemy_id}/collision-rules", + data='{"rules": [{"trigger": "diagonale", "action": {"type": "attaque"}}, {"trigger": "collision", "action": {"type": "sorcellerie"}}, "pas un objet"]}', + content_type="application/json", + ) + assert resp.get_json()["rules"] == [] + + +def test_rules_route_404s_for_missing_object(client, game): + resp = client.post(f"/game/{game}/scene-objects/999999/collision-rules", data='{"rules": []}', content_type="application/json") + assert resp.status_code == 404 + + +def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(client, tmp_game_slug_cleanup): + """Toujours les AUTRES objets qui réagissent à la présence du joueur, + jamais l'inverse — le joueur lui-même et un fond n'ont pas de champ + "collision_rules" dans le payload.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + player_id = _add_personnage(client, slug, screen_id) # rôle par défaut : joueur + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + payload = screens.full_game_payload(slug) + scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id) + player_payload = next(o for o in scene_screen["elements"] if o["id"] == player_id) + enemy_payload = next(o for o in scene_screen["elements"] if o["id"] == enemy_id) + assert "collision_rules" not in player_payload + assert enemy_payload["collision_rules"] == [] + + +def test_scene_edit_view_lists_eligible_objects_only(client, tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + player_id = _add_personnage(client, slug, screen_id) # rôle par défaut : joueur, exclu + enemy_id = _add_personnage(client, slug, screen_id, role="ennemie") + + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True) + idx = html.find("var COLLISION_RULE_OBJECTS") + end = html.find(";", idx) + snippet = html[idx:end] + assert f'"id": {enemy_id}' in snippet + assert f'"id": {player_id}' not in snippet From b64541856fe4dbe8316e4cd86f305a5b4eccc01d Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 17:23:42 +0200 Subject: [PATCH 18/22] =?UTF-8?q?=C3=89diteur=20de=20collision=20:=20carte?= =?UTF-8?q?s=20"pi=C3=A8ces=20qui=20s'embo=C3=AEtent",=20anim=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte visuelle de l'onglet "🧩 Collision" — l'ancien rendu (pastilles plates + flèches) ne rendait pas la métaphore "pièces de puzzle qui s'emboîtent" demandée. Chaque maillon (déclencheur/action/sous-action) est maintenant une pièce chevronnée colorée par type, imbriquée dans la suivante via clip-path + marge négative, avec une entrée animée en cascade. Cartes objet et choix de l'assistant modal redessinés en grille de cartes avec icône + libellé, hover/entrée animés. Purement visuel — aucun changement de logique/données (déjà couvert par tests/test_collision_rules.py et static/js/play/__tests__/collision-rules-controller.test.js). Co-Authored-By: Claude Sonnet 5 --- static/js/scenes/collision-rules-editor.js | 68 +++++++++++++------ static/style.css | 76 ++++++++++++++++++---- 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/static/js/scenes/collision-rules-editor.js b/static/js/scenes/collision-rules-editor.js index 9e1be956..c173c069 100644 --- a/static/js/scenes/collision-rules-editor.js +++ b/static/js/scenes/collision-rules-editor.js @@ -37,22 +37,28 @@ function collisionRuleActionSummary(action) { return COLLISION_ACTION_LABELS[action.type] || action.type; } -// Une règle = une chaîne de "maillons" (déclencheur -> action -> sous-action -// éventuelle) — chacun sa propre petite carte, visuellement emboîtées. +// Une règle = une chaîne de "maillons" en forme de pièces qui s'emboîtent +// (déclencheur -> action -> sous-action éventuelle), pas une liste plate — +// chaque maillon porte un data-kind (couleur en CSS) et un délai d'entrée +// en cascade (voir @keyframes collisionLinkIn, static/style.css). function collisionRuleChainHtml(rule, ruleIndex, objectId) { - var links = [collisionRuleTriggerSummary(rule), collisionRuleActionSummary(rule.action)]; + var links = [ + { kind: 'trigger', text: collisionRuleTriggerSummary(rule) }, + { kind: rule.action.type, text: collisionRuleActionSummary(rule.action) }, + ]; var action = rule.action; while (action.type === 'interagir' && action.sub_action) { - links.push(collisionRuleActionSummary(action.sub_action)); + links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action) }); action = action.sub_action; } - var linksHtml = links.map(function (text) { - return '' + text.replace(/'; - }).join('→'); + var linksHtml = links.map(function (link, i) { + return '' + + link.text.replace(/'; + }).join(''); return ( '
' + '' + - '' + '
' ); @@ -69,7 +75,7 @@ function collisionRuleCardHtml(obj) { '

' + collisionRuleObjectLabel(obj).replace(/' + '

' + '
' + rulesHtml + '
' + - '' + + '' + '
' ); } @@ -81,7 +87,11 @@ function renderCollisionRuleCards() { container.innerHTML = '

Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).

'; return; } - container.innerHTML = COLLISION_RULE_OBJECTS.map(collisionRuleCardHtml).join(''); + // Chaque carte entre en cascade (voir @keyframes collisionCardIn, + // static/style.css) plutôt que toutes en même temps. + container.innerHTML = COLLISION_RULE_OBJECTS.map(function (obj, i) { + return collisionRuleCardHtml(obj).replace('
' + - '' + - '
' + - '' + - '' + - 'px' + - '
' + + '' + + '' + '
'; } @@ -164,8 +187,11 @@ function collisionWizardChooseTrigger(trigger) { // quelle action exécuter. function renderCollisionWizardActionStep(nested) { var types = nested ? COLLISION_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_ACTION_TYPES; - var buttons = types.map(function (type) { - return ''; + var buttons = types.map(function (type, i) { + var split = collisionSplitEmojiLabel(COLLISION_ACTION_LABELS[type]); + return ''; }).join(''); collisionWizardBodyEl().innerHTML = '

' + (nested ? 'Une fois la touche pressée...' : 'Alors...') + '

' + @@ -223,8 +249,10 @@ function collisionWizardChooseAction(type, nested) { collisionWizardBodyEl().innerHTML = '

Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "📣 Événements", puis reviens ici.

'; return; } - var buttons = eventIds.map(function (id) { - return ''; + var buttons = eventIds.map(function (id, i) { + return ''; }).join(''); collisionWizardBodyEl().innerHTML = '

Quel événement ?

' + buttons + '
'; } diff --git a/static/style.css b/static/style.css index ecc17843..930f00bd 100644 --- a/static/style.css +++ b/static/style.css @@ -676,28 +676,80 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d métaphore "pièces qui s'emboîtent" demandée, sans un vrai graphe à tracer à la souris (l'assistant "+ Action" construit la chaîne pas à pas, voir openCollisionRuleModal). */ -.collisionRulesCards{ display:flex; flex-direction:column; gap:14px; margin-top:12px; } +@keyframes collisionCardIn{ from{ opacity:0; transform:translateY(10px) scale(.97); } to{ opacity:1; transform:none; } } +@keyframes collisionLinkIn{ from{ opacity:0; transform:translateX(-10px) scale(.9); } to{ opacity:1; transform:none; } } +@keyframes collisionChoiceIn{ from{ opacity:0; transform:translateY(8px) scale(.92); } to{ opacity:1; transform:none; } } + +.collisionRulesCards{ display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:18px; margin-top:14px; } .collisionRuleCard{ - background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; + position:relative; background:linear-gradient(165deg, var(--panel), var(--panel2)); + border:1px solid var(--border); border-radius:16px; padding:16px; + box-shadow:0 6px 18px rgba(0,0,0,.18); animation:collisionCardIn .35s ease backwards; + transition:transform .18s ease, box-shadow .18s ease; } -.collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:8px; } +.collisionRuleCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); } +.collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:12px; } .collisionRuleCardThumb{ - width:36px; height:36px; border-radius:6px; overflow:hidden; background:var(--panel2); + width:40px; height:40px; border-radius:10px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; flex-shrink:0; + border:1px solid var(--border); box-shadow:inset 0 0 0 2px rgba(255,255,255,.03); } .collisionRuleCardThumb img{ max-width:100%; max-height:100%; object-fit:contain; } -.collisionRuleCardTitle{ margin:0; font-size:14px; } -.collisionRuleCardRules{ display:flex; flex-direction:column; gap:8px; margin-bottom:10px; } +.collisionRuleCardTitle{ margin:0; font-size:15px; font-weight:700; } +.collisionRuleCardRules{ display:flex; flex-direction:column; gap:10px; margin-bottom:14px; } + +/* Une règle = une "chaîne" de maillons en forme de pièces qui s'emboîtent + (déclencheur -> action -> sous-action éventuelle), pas une simple liste + de pastilles à plat — chaque maillon chevauche le précédent via une + marge négative + une pointe en clip-path, coloré selon son type. */ .collisionRuleChain{ - display:flex; align-items:center; justify-content:space-between; gap:8px; - background:var(--panel2); border:1px solid var(--border); border-radius:8px; padding:6px 8px; + display:flex; align-items:center; gap:10px; background:rgba(0,0,0,.15); + border:1px solid var(--border); border-radius:12px; padding:8px 10px; } -.collisionRuleChainLinks{ display:flex; align-items:center; flex-wrap:wrap; gap:6px; } +.collisionRuleChainLinks{ display:flex; align-items:center; flex-wrap:wrap; row-gap:6px; } .collisionRuleLink{ - background:var(--accent); color:#fff; border-radius:12px; padding:3px 10px; font-size:12px; white-space:nowrap; + --link-color:var(--accent); + position:relative; display:inline-flex; align-items:center; gap:4px; + background:linear-gradient(135deg, var(--link-color), color-mix(in srgb, var(--link-color) 70%, #000 30%)); + color:#fff; padding:7px 16px 7px 20px; font-size:12.5px; font-weight:600; white-space:nowrap; + clip-path:polygon(0% 0%, 90% 0%, 100% 50%, 90% 100%, 0% 100%, 10% 50%); + margin-left:-12px; z-index:1; animation:collisionLinkIn .3s ease backwards; + transition:transform .15s ease, filter .15s ease; } -.collisionRuleArrow{ color:var(--forge-text-muted, #8b95a7); font-size:12px; } -.collisionWizardChoices{ display:flex; flex-direction:column; } +.collisionRuleLink:first-child{ margin-left:0; clip-path:polygon(0% 0%, 90% 0%, 100% 50%, 90% 100%, 0% 100%); padding-left:14px; border-radius:8px 0 0 8px; } +.collisionRuleLink:hover{ transform:translateY(-2px) scale(1.04); filter:brightness(1.12); z-index:2; } +.collisionRuleLink[data-kind="trigger"]{ --link-color:#f2994a; } +.collisionRuleLink[data-kind="quete"]{ --link-color:#9b59b6; } +.collisionRuleLink[data-kind="attaque"]{ --link-color:#e0524d; } +.collisionRuleLink[data-kind="evenement"]{ --link-color:#17a2b8; } +.collisionRuleLink[data-kind="interagir"]{ --link-color:#d4a72c; } +.collisionRuleChainDelete{ + margin-left:auto; flex-shrink:0; width:26px; height:26px; border-radius:50%; border:none; + background:rgba(224,82,77,.15); color:#e0524d; display:flex; align-items:center; justify-content:center; + cursor:pointer; transition:background .15s ease, transform .15s ease; font-size:13px; +} +.collisionRuleChainDelete:hover{ background:#e0524d; color:#fff; transform:scale(1.08); } + +.collisionRuleAddBtn{ + display:inline-flex; align-items:center; gap:6px; background:linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, #000 30%)); + color:#fff; border:none; border-radius:999px; padding:8px 16px; font-size:13px; font-weight:700; + cursor:pointer; box-shadow:0 4px 12px rgba(0,0,0,.25); transition:transform .15s ease, box-shadow .15s ease; +} +.collisionRuleAddBtn:hover{ transform:translateY(-2px) scale(1.03); box-shadow:0 6px 18px rgba(0,0,0,.35); } +.collisionRuleAddBtn:active{ transform:translateY(0) scale(.98); } + +/* Assistant "+ Action" — une grille de cartes-choix animées en cascade + plutôt qu'une pile de boutons pleine largeur. */ +.collisionWizardChoices{ display:grid; grid-template-columns:repeat(auto-fill, minmax(190px, 1fr)); gap:10px; } +.collisionWizardChoice{ + display:flex; flex-direction:column; align-items:flex-start; gap:4px; text-align:left; + background:var(--panel2); border:1px solid var(--border); border-radius:12px; padding:12px 14px; + cursor:pointer; font-size:13.5px; font-weight:600; color:inherit; + animation:collisionChoiceIn .25s ease backwards; transition:transform .15s ease, border-color .15s ease, box-shadow .15s ease; +} +.collisionWizardChoice:hover{ transform:translateY(-3px) scale(1.02); border-color:var(--accent); box-shadow:0 8px 18px rgba(0,0,0,.25); } +.collisionWizardChoice .emoji{ font-size:22px; } + .collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; } .listRow.selected{ border-color:var(--accent); background:var(--panel2); } From 4391b36d45d51eb5f4f04e91de257d30c48077c3 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 19:08:45 +0200 Subject: [PATCH 19/22] =?UTF-8?q?Fix=20miniature=20de=20carte=20"?= =?UTF-8?q?=F0=9F=A7=A9=20Collision"=20=C3=A9chapp=C3=A9e=20du=20cadre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_scene_object.py pose un positionnement en pixels absolus pensé pour le canevas de scène (position:absolute, left/top/width/height en dur, coordonnées d'origine de l'objet) — réinjecté tel quel comme simple miniature 40×40 dans la carte, l'image se plaçait à ses coordonnées de scène d'origine au lieu de rester dans son cadre. Écrase ces styles en !important côté miniature seulement. Co-Authored-By: Claude Sonnet 5 --- static/style.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/static/style.css b/static/style.css index 930f00bd..a158bbc6 100644 --- a/static/style.css +++ b/static/style.css @@ -690,11 +690,19 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .collisionRuleCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); } .collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:12px; } .collisionRuleCardThumb{ - width:40px; height:40px; border-radius:10px; overflow:hidden; background:var(--panel2); + position:relative; width:40px; height:40px; border-radius:10px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; flex-shrink:0; border:1px solid var(--border); box-shadow:inset 0 0 0 2px rgba(255,255,255,.03); } -.collisionRuleCardThumb img{ max-width:100%; max-height:100%; object-fit:contain; } +/* obj["rendered_html"] (render_scene_object.py) porte un positionnement en + PIXELS ABSOLUS pensé pour le canevas de scène (position:absolute, + left/top/width/height en dur) — réinjecté tel quel ici comme simple + miniature, donc écrasé en !important pour rester DANS le cadre 40×40 + plutôt que de s'échapper à ses coordonnées de scène d'origine. */ +.collisionRuleCardThumb img{ + position:static !important; left:auto !important; top:auto !important; + width:100% !important; height:100% !important; max-width:100%; max-height:100%; object-fit:contain; +} .collisionRuleCardTitle{ margin:0; font-size:15px; font-weight:700; } .collisionRuleCardRules{ display:flex; flex-direction:column; gap:10px; margin-bottom:14px; } From 51427b5297b8d67ad55b18add4919cf9b346aa69 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 20:24:57 +0200 Subject: [PATCH 20/22] =?UTF-8?q?=C3=89diteur=20de=20qu=C3=AAtes=20:=20tit?= =?UTF-8?q?re/objectif/r=C3=A9compense/statut/r=C3=A9sultat=20+=20dialogue?= =?UTF-8?q?=20=C3=A0=203=20colonnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvel espace "🗺️ Quêtes" (game-wide, comme les événements personnalisés) : liste de cartes + "+ Nouvelle quête", chaque carte ouvrant une modale d'arbre de dialogue en bulles de théâtre ("speaker: texte"), une colonne par statut de quête (nouvelle/en cours/terminée) puisque le dialogue joué dépend de l'état de la quête au moment où le joueur parle au PNJ. Bulles alternées joueur/PNJ, couleur différente selon qui parle. L'action "Déclencher une quête" de l'éditeur de collision n'est plus un champ texte libre : elle ouvre directement CETTE MÊME modale (picker de quêtes existantes + création à la volée), et finalise la règle avec l'id numérique de la quête choisie — collision_rules.py migré en conséquence (quete_id devient un entier, comme evenement_id). Corrige aussi la miniature d'objet de la carte "🧩 Collision", 3× plus grande (120px) comme demandé. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 14 + db/quests/constants.py | 5 + db/quests/create_quest.py | 18 ++ db/quests/delete_quest.py | 10 + db/quests/ensure_quests_schema.py | 29 ++ db/quests/get_quest.py | 11 + db/quests/list_quests.py | 22 ++ db/quests/sanitize_quest_dialogues.py | 30 ++ db/quests/set_quest_dialogues.py | 21 ++ db/quests/update_quest.py | 42 +++ routes/__init__.py | 2 +- routes/quests/__init__.py | 1 + routes/quests/quest_delete.py | 11 + routes/quests/quest_dialogues.py | 17 ++ routes/quests/quest_new.py | 12 + routes/quests/quest_update.py | 14 + routes/quests/quests_list_json.py | 14 + routes/quests/quests_view.py | 20 ++ routes/scenes/scene_edit_view.py | 6 + screens/rendering/collision_rules.py | 16 +- static/js/quests/quest-editor.js | 301 +++++++++++++++++++++ static/js/scenes/collision-rules-editor.js | 27 +- static/style.css | 60 +++- templates/base.html | 1 + templates/quests.html | 17 ++ templates/scene_edit.html | 5 + tests/test_collision_rules.py | 12 +- tests/test_quests.py | 172 ++++++++++++ 28 files changed, 881 insertions(+), 29 deletions(-) create mode 100644 db/quests/constants.py create mode 100644 db/quests/create_quest.py create mode 100644 db/quests/delete_quest.py create mode 100644 db/quests/ensure_quests_schema.py create mode 100644 db/quests/get_quest.py create mode 100644 db/quests/list_quests.py create mode 100644 db/quests/sanitize_quest_dialogues.py create mode 100644 db/quests/set_quest_dialogues.py create mode 100644 db/quests/update_quest.py create mode 100644 routes/quests/__init__.py create mode 100644 routes/quests/quest_delete.py create mode 100644 routes/quests/quest_dialogues.py create mode 100644 routes/quests/quest_new.py create mode 100644 routes/quests/quest_update.py create mode 100644 routes/quests/quests_list_json.py create mode 100644 routes/quests/quests_view.py create mode 100644 static/js/quests/quest-editor.js create mode 100644 templates/quests.html create mode 100644 tests/test_quests.py diff --git a/db/__init__.py b/db/__init__.py index 8cab4a08..9d49d31f 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -78,6 +78,17 @@ from .custom_events.get_custom_event import get_custom_event 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, +) +from .quests.list_quests import list_quests +from .quests.get_quest import get_quest +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 + __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", @@ -95,4 +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", + "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 new file mode 100644 index 00000000..ca54a481 --- /dev/null +++ b/db/quests/constants.py @@ -0,0 +1,5 @@ +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/create_quest.py b/db/quests/create_quest.py new file mode 100644 index 00000000..83522dc3 --- /dev/null +++ b/db/quests/create_quest.py @@ -0,0 +1,18 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest + + +def create_quest(slug, title="Nouvelle quête"): + """Titre par défaut plutôt qu'un refus — créée d'un clic depuis la + liste (voir templates/quests.html) ou depuis l'assistant "+ Action" + de l'éditeur de collision (voir collision-rules-editor.js), avant + même que l'auteur ait choisi un titre.""" + ensure_quests_schema(slug) + title = (title or "").strip() or "Nouvelle quête" + conn = connect(slug) + conn.execute("INSERT INTO _quests (title) VALUES (?)", (title,)) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return get_quest(slug, new_id) diff --git a/db/quests/delete_quest.py b/db/quests/delete_quest.py new file mode 100644 index 00000000..80e19b75 --- /dev/null +++ b/db/quests/delete_quest.py @@ -0,0 +1,10 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema + + +def delete_quest(slug, quest_id): + ensure_quests_schema(slug) + conn = connect(slug) + conn.execute("DELETE FROM _quests WHERE id = ?", (quest_id,)) + conn.commit() + conn.close() diff --git a/db/quests/ensure_quests_schema.py b/db/quests/ensure_quests_schema.py new file mode 100644 index 00000000..8013cd81 --- /dev/null +++ b/db/quests/ensure_quests_schema.py @@ -0,0 +1,29 @@ +from ..connection import connect + + +def ensure_quests_schema(slug): + """Migration légère (même principe que ensure_custom_events_schema.py) : + crée _quests si absente. Une quête vit pour TOUT le jeu (pas par écran), + référencée par id technique depuis l'action "quete" d'une règle de + collision (voir screens/rendering/collision_rules.py, quete_id). Les + dialogues (voir static/js/quests/quest-editor.js — 3 colonnes + nouvelle/en_cours/terminee) sont stockés en JSON plutôt qu'une table à + part : jamais interrogés/filtrés côté SQL, toujours lus/réécrits en + bloc pour UNE quête à la fois (même convention que _scene_objects.attributes).""" + conn = connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _quests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + objectif TEXT DEFAULT '', + recompense_score INTEGER DEFAULT 0, + status TEXT DEFAULT 'nouvelle', + resultat TEXT, + dialogues_json TEXT DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/db/quests/get_quest.py b/db/quests/get_quest.py new file mode 100644 index 00000000..634d6e70 --- /dev/null +++ b/db/quests/get_quest.py @@ -0,0 +1,11 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .list_quests import _row_to_quest + + +def get_quest(slug, quest_id): + ensure_quests_schema(slug) + conn = connect(slug) + row = conn.execute("SELECT * FROM _quests WHERE id = ?", (quest_id,)).fetchone() + conn.close() + return _row_to_quest(row) if row else None diff --git a/db/quests/list_quests.py b/db/quests/list_quests.py new file mode 100644 index 00000000..734a160a --- /dev/null +++ b/db/quests/list_quests.py @@ -0,0 +1,22 @@ +import json + +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema + + +def _row_to_quest(row): + quest = dict(row) + try: + quest["dialogues"] = json.loads(quest.pop("dialogues_json") or "{}") + except (TypeError, ValueError): + quest["dialogues"] = {} + quest.pop("dialogues_json", None) + return quest + + +def list_quests(slug): + ensure_quests_schema(slug) + conn = connect(slug) + rows = conn.execute("SELECT * FROM _quests ORDER BY created_at DESC, id DESC").fetchall() + conn.close() + return [_row_to_quest(r) for r in rows] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py new file mode 100644 index 00000000..dfaef61d --- /dev/null +++ b/db/quests/sanitize_quest_dialogues.py @@ -0,0 +1,30 @@ +from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES + +_MAX_LINES_PER_COLUMN = 200 + + +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.""" + result = {status: [] for status in QUEST_STATUS_CHOICES} + if not isinstance(raw_dialogues, dict): + return result + for status in QUEST_STATUS_CHOICES: + 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 speaker not in QUEST_SPEAKER_CHOICES: + continue + if not isinstance(text, str) or not text.strip(): + continue + cleaned.append({"speaker": speaker, "text": text.strip()}) + result[status] = cleaned + return result diff --git a/db/quests/set_quest_dialogues.py b/db/quests/set_quest_dialogues.py new file mode 100644 index 00000000..62a4ed65 --- /dev/null +++ b/db/quests/set_quest_dialogues.py @@ -0,0 +1,21 @@ +import json + +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest +from .sanitize_quest_dialogues import sanitize_quest_dialogues + + +def set_quest_dialogues(slug, quest_id, raw_dialogues): + """Toujours les 3 colonnes ENTIÈRES (nouvelle/en_cours/terminee) en un + bloc — même convention "replace-all" que set_scene_object_collision_rules.py, + jamais une réplique isolée.""" + ensure_quests_schema(slug) + if get_quest(slug, quest_id) is None: + return None + dialogues = sanitize_quest_dialogues(raw_dialogues) + conn = connect(slug) + conn.execute("UPDATE _quests SET dialogues_json = ? WHERE id = ?", (json.dumps(dialogues), quest_id)) + conn.commit() + conn.close() + return dialogues diff --git a/db/quests/update_quest.py b/db/quests/update_quest.py new file mode 100644 index 00000000..f8fec251 --- /dev/null +++ b/db/quests/update_quest.py @@ -0,0 +1,42 @@ +from ..connection import connect +from .constants import QUEST_STATUS_CHOICES, QUEST_RESULT_CHOICES +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest + + +def update_quest(slug, quest_id, fields): + """Met à jour uniquement les clés reconnues et valides de `fields` — + ignore tout le reste plutôt que de lever, même esprit que + sanitize_collision_rules (jamais confiance dans ce qu'envoie le + client). title vide => ignoré (garde l'ancien plutôt qu'un titre vide).""" + ensure_quests_schema(slug) + if get_quest(slug, quest_id) is None: + return None + updates = {} + if "title" in fields: + title = (fields["title"] or "").strip() + if title: + updates["title"] = title + if "objectif" in fields: + updates["objectif"] = (fields["objectif"] or "").strip() + if "recompense_score" in fields: + try: + updates["recompense_score"] = int(fields["recompense_score"]) + except (TypeError, ValueError): + pass + if "status" in fields and fields["status"] in QUEST_STATUS_CHOICES: + updates["status"] = fields["status"] + if "resultat" in fields: + resultat = fields["resultat"] + if resultat in QUEST_RESULT_CHOICES: + updates["resultat"] = resultat + elif resultat is None: + updates["resultat"] = None + if not updates: + return get_quest(slug, quest_id) + conn = connect(slug) + set_clause = ", ".join(f"{key} = ?" for key in updates) + conn.execute(f"UPDATE _quests SET {set_clause} WHERE id = ?", (*updates.values(), quest_id)) + conn.commit() + conn.close() + return get_quest(slug, quest_id) diff --git a/routes/__init__.py b/routes/__init__.py index 13e82f50..0a52327b 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 +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 diff --git a/routes/quests/__init__.py b/routes/quests/__init__.py new file mode 100644 index 00000000..0bbd7d75 --- /dev/null +++ b/routes/quests/__init__.py @@ -0,0 +1 @@ +from . import quests_view, quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues diff --git a/routes/quests/quest_delete.py b/routes/quests/quest_delete.py new file mode 100644 index 00000000..c726589d --- /dev/null +++ b/routes/quests/quest_delete.py @@ -0,0 +1,11 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//quests//delete", methods=["POST"]) +def quest_delete(slug, quest_id): + db.delete_quest(slug, quest_id) + return jsonify({"ok": True}) diff --git a/routes/quests/quest_dialogues.py b/routes/quests/quest_dialogues.py new file mode 100644 index 00000000..2f318de1 --- /dev/null +++ b/routes/quests/quest_dialogues.py @@ -0,0 +1,17 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests//dialogues", methods=["POST"]) +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: + return jsonify({"error": "quête introuvable"}), 404 + return jsonify({"dialogues": dialogues}) diff --git a/routes/quests/quest_new.py b/routes/quests/quest_new.py new file mode 100644 index 00000000..a7b33ebc --- /dev/null +++ b/routes/quests/quest_new.py @@ -0,0 +1,12 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests/new", methods=["POST"]) +def quest_new(slug): + body = request.get_json(silent=True) or {} + quest = db.create_quest(slug, body.get("title")) + return jsonify({"quest": quest}) diff --git a/routes/quests/quest_update.py b/routes/quests/quest_update.py new file mode 100644 index 00000000..2c922c82 --- /dev/null +++ b/routes/quests/quest_update.py @@ -0,0 +1,14 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests/", methods=["POST"]) +def quest_update(slug, quest_id): + body = request.get_json(silent=True) or {} + quest = db.update_quest(slug, quest_id, body) + if quest is None: + return jsonify({"error": "quête introuvable"}), 404 + return jsonify({"quest": quest}) diff --git a/routes/quests/quests_list_json.py b/routes/quests/quests_list_json.py new file mode 100644 index 00000000..94dbed50 --- /dev/null +++ b/routes/quests/quests_list_json.py @@ -0,0 +1,14 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//quests/list.json") +def quests_list_json(slug): + """Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir + collision-rules-editor.js) pour lister les quêtes existantes sans + quitter la modale déjà ouverte — même contenu que quests_view, en JSON + seul plutôt qu'une page complète.""" + return jsonify({"quests": db.list_quests(slug)}) diff --git a/routes/quests/quests_view.py b/routes/quests/quests_view.py new file mode 100644 index 00000000..f9afc130 --- /dev/null +++ b/routes/quests/quests_view.py @@ -0,0 +1,20 @@ +import json + +from flask import render_template + +import db + +from core.flask_app import app + + +@app.route("/game//quests") +def quests_view(slug): + """Éditeur de quêtes (voir db/quests/) — page dédiée game-wide, dans le + même esprit que game_dashboard.html : une liste de cartes + un bouton + "+ Nouvelle quête" au-dessus, chaque carte ouvrant une modale avec + l'arbre de dialogue (static/js/quests/quest-editor.js — même module + JS que celui rendu depuis l'assistant "+ Action" de l'éditeur de + collision, voir collision-rules-editor.js).""" + game = db.game_meta(slug) + quests = db.list_quests(slug) + return render_template("quests.html", game=game, quests_json=json.dumps(quests)) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index c8fb5c11..75d1fa0d 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -128,4 +128,10 @@ def render_scene_edit(slug, screen_id, game): collision_rule_objects_json=json.dumps(collision_rule_objects), collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, + # Éditeur de quêtes (voir db/quests/) : préchargées ici pour que + # collisionRuleActionSummary (collision-rules-editor.js) affiche + # tout de suite le TITRE de la quête référencée par une règle + # 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)), ) diff --git a/screens/rendering/collision_rules.py b/screens/rendering/collision_rules.py index 738e2ebc..330f60a2 100644 --- a/screens/rendering/collision_rules.py +++ b/screens/rendering/collision_rules.py @@ -11,9 +11,13 @@ import json # joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). # # Action (une seule par règle) : -# - "quete" : quete_id (référence libre — l'éditeur de quête lui-même -# reste à construire, cette action ne fait QUE mémoriser -# la référence pour l'instant). +# - "quete" : quete_id (id technique d'une quête — voir db/quests/, +# choisie/créée via l'assistant "+ Action" qui ouvre +# directement l'éditeur de quête, static/js/quests/ +# quest-editor.js) — une quête inexistante/supprimée +# depuis reste une référence orpheline plutôt qu'une +# contrainte SQL, comme le reste de ce moteur (voir +# evenement_id ci-dessous, même principe). # - "attaque" : joue la pose "attack" du personnage si disponible (voir # personnage-controller.js) — pas de système de combat # (dégâts/PV...) pour l'instant, effet visuel seul. @@ -45,10 +49,10 @@ def _sanitize_action(raw_action, depth=1): return None # un seul niveau d'imbrication autorisé action = {"type": action_type} if action_type == "quete": - quete_id = raw_action.get("quete_id") - if not (isinstance(quete_id, str) and quete_id.strip()): + try: + action["quete_id"] = int(raw_action.get("quete_id")) + except (TypeError, ValueError): return None - action["quete_id"] = quete_id.strip() elif action_type == "evenement": try: action["evenement_id"] = int(raw_action.get("evenement_id")) diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js new file mode 100644 index 00000000..5b820af9 --- /dev/null +++ b/static/js/quests/quest-editor.js @@ -0,0 +1,301 @@ +// ---------- Éditeur de quêtes ---------- +// Module partagé entre templates/quests.html (page dédiée : liste de +// cartes + "+ Nouvelle quête") et static/js/scenes/collision-rules-editor.js +// (l'assistant "+ Action" de l'éditeur de collision ouvre CETTE MÊME +// modale de dialogue quand l'auteur choisit "Déclencher une quête", +// plutôt qu'un simple champ texte pour un id). La modale (arbre de +// dialogue) est créée dynamiquement (ensureQuestDialogueModal) plutôt que +// dupliquée dans deux templates. +// +// Une quête (voir db/quests/) : id, title, objectif, recompense_score, +// status (nouvelle/en_cours/terminee — statut COURANT, informatif), +// resultat (reussi/echoue), dialogues (une liste de répliques PAR +// STATUT — 3 colonnes dans l'éditeur : quel dialogue jouer selon l'état +// de la quête au moment où le joueur parle au PNJ). + +var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' }; +var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' }; +var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; + +// Quêtes connues côté client — préremplies par QUESTS_JSON (quests.html) +// si présent, sinon vide tant qu'on n'a pas encore fetch (contexte +// éditeur de collision, voir questPickerEnsureLoaded). +var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; + +function questById(questId) { + return QUESTS.find(function (q) { return q.id === questId; }); +} + +function questCardHtml(quest, i) { + var resultatHtml = quest.resultat ? '' + QUEST_RESULT_LABELS[quest.resultat] + '' : ''; + return ( + '
' + + '
' + + '

' + (quest.title || '').replace(/' + + '' + QUEST_STATUS_LABELS[quest.status] + '' + + '

' + + '

' + (quest.objectif ? quest.objectif.replace(/Aucun objectif défini.') + '

' + + '
' + + '🏆 +' + (quest.recompense_score || 0) + '' + + resultatHtml + + '
' + + '
' + ); +} + +function renderQuestListPage() { + var container = document.getElementById('questsGrid'); + if (!container) return; + if (!QUESTS.length) { + container.innerHTML = '

Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.

'; + return; + } + container.innerHTML = QUESTS.map(questCardHtml).join(''); +} + +function createQuestFromListPage() { + fetch('/game/' + GAME_SLUG + '/quests/new', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + QUESTS.unshift(json.quest); + renderQuestListPage(); + openQuestDialogueModal(json.quest.id); + }); +} + +// ---------- Modale "arbre de dialogue" ---------- +// Créée une seule fois (au premier appel), réutilisée ensuite — partagée +// entre la page dédiée et l'assistant "+ Action" de l'éditeur de +// collision, jamais dupliquée dans les templates. +var _questModalOnUse = null; + +function ensureQuestDialogueModal() { + var modal = document.getElementById('questDialogueModal'); + if (modal) return modal; + modal = document.createElement('div'); + modal.className = 'modal'; + modal.id = 'questDialogueModal'; + modal.innerHTML = + '' + + ''; + document.body.appendChild(modal); + return modal; +} + +// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...) +// pour finaliser la règle de collision avec cette quête, affiche alors un +// bouton "✓ Utiliser cette quête" épinglé en haut de la modale. +function openQuestDialogueModal(questId, onUse) { + _questModalOnUse = onUse || null; + var modal = ensureQuestDialogueModal(); + modal.classList.add('is-active'); + renderQuestDialogueModalBody(questId); +} + +function closeQuestDialogueModal() { + var modal = document.getElementById('questDialogueModal'); + if (modal) modal.classList.remove('is-active'); + _questModalOnUse = null; + _questModalCurrentId = null; +} + +function questUseThisQuest(questId) { + var quest = questById(questId); + if (_questModalOnUse && quest) _questModalOnUse(quest); + closeQuestDialogueModal(); +} + +function questFieldRowHtml(quest) { + return ( + '
' + + '" + + '" + + '" + + '' + + '' + + '
' + ); +} + +function questDialogueBubbleHtml(line, status, index) { + return ( + '
' + + '
' + + '
' + + (line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') + + '
' + + '' + + '' + + '
' + + '
' + ); +} + +function questDialogueColumnHtml(quest, status) { + var lines = (quest.dialogues && quest.dialogues[status]) || []; + var bubblesHtml = lines.map(function (line, i) { return questDialogueBubbleHtml(line, status, i); }).join(''); + return ( + '
' + + '

' + QUEST_STATUS_LABELS[status] + '

' + + '
' + bubblesHtml + '
' + + '' + + '
' + ); +} + +var _questModalCurrentId = null; + +function renderQuestDialogueModalBody(questId) { + var quest = questById(questId); + var body = document.getElementById('questDialogueModalBody'); + if (!quest || !body) return; + _questModalCurrentId = questId; + var useBtn = _questModalOnUse + ? '' + : ''; + body.innerHTML = + '
' + + '

🗺️ ' + (quest.title || '').replace(/' + + '
' + useBtn + + '' + + '
' + + '

' + + questFieldRowHtml(quest) + + '

Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :

' + + '
' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '
'; +} + +function questUpdateField(questId, field, value) { + var quest = questById(questId); + if (!quest) return; + var body = {}; + body[field] = field === 'recompense_score' ? Number(value) : (value || null); + 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); + renderQuestListPage(); + if (document.getElementById('questDialogueModal') && document.getElementById('questDialogueModal').classList.contains('is-active')) { + renderQuestDialogueModalBody(questId); + } + }); +} + +// Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir +// db/quests/set_quest_dialogues.py) — jamais une réplique isolée. +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; }); +} + +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: '' }); + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questToggleSpeaker(status, index) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + var line = quest.dialogues[status][index]; + line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur'; + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questUpdateBubbleText(status, index, text) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].text = text; + questSaveDialogues(quest); +} + +function questDeleteBubble(status, index) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status].splice(index, 1); + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function _currentQuestModalId() { + return _questModalCurrentId; +} + +// ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ---------- +// Récupère la liste à jour (jamais présente d'avance dans scene_edit.html, +// contrairement à quests.html) puis affiche des cartes + "+ Nouvelle +// quête" DANS la modale de collision déjà ouverte (voir +// renderCollisionWizardActionStep) ; choisir/créer une quête ouvre +// ENSUITE la modale de dialogue (celle-ci empilée par-dessus), avec un +// bouton "✓ Utiliser cette quête" qui referme les deux et finalise la +// règle — exactement le comportement demandé : cliquer "Déclencher une +// quête" ouvre l'éditeur de quête, pas un simple champ texte. +function questPickerHtml(quests) { + if (!quests.length) { + return '

Aucune quête pour l\'instant.

'; + } + return '
' + quests.map(function (q, i) { + return ''; + }).join('') + '
'; +} + +var _questPickerOnUse = null; + +function renderQuestPickerStep(onUse) { + _questPickerOnUse = onUse; + var body = collisionWizardBodyEl(); + body.innerHTML = '

Quelle quête déclencher ?

Chargement…
' + + ''; + fetch('/game/' + GAME_SLUG + '/quests/list.json') + .then(function (r) { return r.json(); }) + .then(function (json) { + QUESTS = json.quests; + var list = document.getElementById('questPickerList'); + if (list) list.innerHTML = questPickerHtml(QUESTS); + }); +} + +function questPickerChoose(questId) { + openQuestDialogueModal(questId, _questPickerOnUse); +} + +function questPickerCreate() { + fetch('/game/' + GAME_SLUG + '/quests/new', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + QUESTS.unshift(json.quest); + openQuestDialogueModal(json.quest.id, _questPickerOnUse); + }); +} + +document.addEventListener('DOMContentLoaded', function () { + if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage(); +}); diff --git a/static/js/scenes/collision-rules-editor.js b/static/js/scenes/collision-rules-editor.js index c173c069..b2ed1b68 100644 --- a/static/js/scenes/collision-rules-editor.js +++ b/static/js/scenes/collision-rules-editor.js @@ -29,7 +29,10 @@ function collisionRuleTriggerSummary(rule) { } function collisionRuleActionSummary(action) { - if (action.type === 'quete') return COLLISION_ACTION_LABELS.quete + ' « ' + action.quete_id + ' »'; + if (action.type === 'quete') { + var quest = (typeof questById === 'function') ? questById(action.quete_id) : null; + return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »'; + } if (action.type === 'evenement') { var ev = CUSTOM_EVENTS_MAP[action.evenement_id]; return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »'; @@ -227,20 +230,19 @@ function collisionWizardSaveRule(action) { } // Chaque type d'action demande éventuellement une précision avant de -// pouvoir se finaliser (quête -> son id ; événement -> lequel) — "attaque" -// n'a besoin de rien de plus, se finalise directement. +// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir +// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple +// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de +// plus, se finalise directement. function collisionWizardChooseAction(type, nested) { if (type === 'attaque' || type === 'interagir') { collisionWizardFinalizeAction({ type: type }, nested); return; } if (type === 'quete') { - collisionWizardBodyEl().innerHTML = - '

Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :

' + - '
' + - '
' + - '
' + - '
'; + renderQuestPickerStep(function (quest) { + collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested); + }); return; } if (type === 'evenement') { @@ -258,13 +260,6 @@ function collisionWizardChooseAction(type, nested) { } } -function collisionWizardSubmitQuete(nested) { - var input = document.getElementById('collisionWizardQueteId'); - var queteId = input && input.value.trim(); - if (!queteId) { input.focus(); return; } - collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested); -} - document.addEventListener('DOMContentLoaded', function () { if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards(); }); diff --git a/static/style.css b/static/style.css index a158bbc6..8944360b 100644 --- a/static/style.css +++ b/static/style.css @@ -690,7 +690,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .collisionRuleCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); } .collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:12px; } .collisionRuleCardThumb{ - position:relative; width:40px; height:40px; border-radius:10px; overflow:hidden; background:var(--panel2); + position:relative; width:120px; height:120px; border-radius:10px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; flex-shrink:0; border:1px solid var(--border); box-shadow:inset 0 0 0 2px rgba(255,255,255,.03); } @@ -760,6 +760,64 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; } +/* ---- Éditeur de quêtes (templates/quests.html + modale de dialogue + partagée avec l'assistant "+ Action" de l'éditeur de collision, voir + static/js/quests/quest-editor.js) ---- */ +.questsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:18px; margin-top:14px; } +.questCard{ + background:linear-gradient(165deg, var(--panel), var(--panel2)); border:1px solid var(--border); + border-radius:16px; padding:16px; cursor:pointer; box-shadow:0 6px 18px rgba(0,0,0,.18); + animation:collisionCardIn .35s ease backwards; transition:transform .18s ease, box-shadow .18s ease; +} +.questCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); border-color:var(--accent); } +.questCardHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; } +.questCardTitle{ margin:0; font-size:15px; font-weight:700; } +.questCardObjectif{ font-size:12.5px; color:var(--forge-text-muted, #8b95a7); margin-bottom:12px; min-height:1.4em; } +.questCardFooter{ display:flex; gap:8px; flex-wrap:wrap; } +.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; } +.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; } +.questPill-resultat{ background:rgba(23,162,184,.18); color:#17a2b8; } +.questPill-status{ background:var(--panel2); border:1px solid var(--border); } +.questPill-status-nouvelle{ background:rgba(155,89,182,.18); color:#9b59b6; border-color:transparent; } +.questPill-status-en_cours{ background:rgba(242,153,74,.18); color:#f2994a; border-color:transparent; } +.questPill-status-terminee{ background:rgba(46,204,113,.18); color:#2ecc71; border-color:transparent; } + +.questDialogueModalContent{ max-width:min(92vw, 1080px); } +.questDialogueModalHeader{ 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; } +.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; +} +.questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; } +.questDialogueColumnBubbles{ display:flex; flex-direction:column; gap:8px; flex:1; } +.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } + +.questBubbleRow{ display:flex; } +.questBubbleRow-pnj{ justify-content:flex-start; } +.questBubbleRow-joueur{ justify-content:flex-end; } +.questBubble{ + display:flex; flex-direction:column; gap:4px; max-width:88%; border-radius:14px; padding:8px 10px; + animation:collisionLinkIn .25s ease backwards; +} +.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; } +.questBubbleText{ + resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px; + font-family:inherit; padding:0; min-height:1.4em; +} +.questBubbleText::placeholder{ color:rgba(255,255,255,.6); } +.questBubbleText:focus{ outline:none; } +.questBubbleDelete{ + align-self:flex-end; background:none; border:none; color:rgba(255,255,255,.6); cursor:pointer; + font-size:11px; padding:0; transition:color .15s ease; +} +.questBubbleDelete:hover{ color:#fff; } + .listRow.selected{ border-color:var(--accent); background:var(--panel2); } .childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); } diff --git a/templates/base.html b/templates/base.html index 3392f0ef..cc21362c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -53,6 +53,7 @@ une version simplifiée (juste les écrans) pour un type "restreint" (quiz/embranchement/rpg), la version complète pour "custom". --> 📊 Tableau de bord + 🗺️ Quêtes ▶️ Jouer 📊 Tableau de bord - 🗺️ Quêtes ▶️ Jouer +
+
+

🗺️ Quêtes

+ +
+

Titre, objectif, récompense, statut, résultat — clique sur une ligne pour écrire son enchaînement de dialogue.

+
+
+

Événements personnalisés

diff --git a/tests/test_quests.py b/tests/test_quests.py index cf96b276..49559c17 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -1,5 +1,5 @@ -"""Éditeur de quêtes (voir db/quests/, routes/quests/, nouveau lien -"🗺️ Quêtes" dans templates/base.html) : id, titre, objectif, récompense +"""Éditeur de quêtes (voir db/quests/, routes/quests/, onglet "🗺️ Quêtes" +de l'éditeur de scène 2D, templates/scene_edit.html) : id, titre, objectif, récompense (score), statut (nouvelle/en_cours/terminee), résultat (reussi/echoue), et un dialogue par statut (3 colonnes — voir static/js/quests/ quest-editor.js et sanitize_quest_dialogues). Une quête vit pour TOUT le @@ -108,14 +108,6 @@ def test_set_quest_dialogues_returns_none_for_missing_quest(tmp_game_slug_cleanu # ---------- Routes ---------- -def test_quests_view_route_renders_the_list(client, tmp_game_slug_cleanup): - slug = _make_game(tmp_game_slug_cleanup) - db.create_quest(slug, "Trouver la clé") - resp = client.get(f"/game/{slug}/quests") - assert resp.status_code == 200 - assert b"Trouver la cl" in resp.data - - def test_quest_new_route_creates_and_returns_json(client, tmp_game_slug_cleanup): slug = _make_game(tmp_game_slug_cleanup) resp = client.post(f"/game/{slug}/quests/new", data='{"title": "Nouvelle quête du donjon"}', content_type="application/json") From 390ee831ca1c5f4c547ad6b25d61e4277e2926df Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 20:54:00 +0200 Subject: [PATCH 22/22] =?UTF-8?q?Qu=C3=AAtes=20:=20dialogue=20dans=20une?= =?UTF-8?q?=20modale=20=C3=A0=20part,=20en=20plein=20=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sépare la modale unique en deux étapes distinctes demandées par l'utilisateur : #questFieldsModal ("je crée" — titre/objectif/ récompense/statut/résultat, taille normale) puis, sur un bouton dédié "💬 Ajouter les dialogues", #questDialogueModal ("j'ajoute les dialogues" — les 3 colonnes de répliques SEULES, occupant tout l'écran). Un bouton "←" ramène à la fiche ; "✓ Utiliser cette quête" reste disponible dans les deux, pour l'intégration avec l'éditeur de collision. Co-Authored-By: Claude Sonnet 5 --- static/js/quests/quest-editor.js | 154 ++++++++++++++++++++++--------- static/style.css | 10 +- 2 files changed, 120 insertions(+), 44 deletions(-) diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index ce52df40..452def22 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -1,13 +1,15 @@ // ---------- Éditeur de quêtes ---------- // Onglet "🗺️ Quêtes" à côté de "🧩 Collision" (templates/scene_edit.html, // jamais une page à part) : une LISTE (mêmes tableau/lignes que l'onglet -// "📣 Événements") plutôt qu'une grille de cartes, chaque ligne portant -// toutes les infos de la quête + un clic dessus ouvrant la modale -// d'arbre de dialogue. Cette même modale est aussi ouverte depuis -// l'assistant "+ Action" de l'éditeur de collision (voir -// static/js/scenes/collision-rules-editor.js) quand l'auteur choisit -// "Déclencher une quête" — créée dynamiquement (ensureQuestDialogueModal) -// plutôt que dupliquée dans deux endroits. +// "📣 Événements") plutôt qu'une grille de cartes. Un clic sur une ligne +// ouvre la modale de FICHE (titre/objectif/récompense/statut/résultat — +// "je crée"), qui ouvre à son tour, sur un bouton dédié, la modale +// PLEIN ÉCRAN d'arbre de dialogue ("j'ajoute les dialogues") — deux +// étapes séparées, jamais le même formulaire. Les deux modales sont +// aussi ouvertes depuis l'assistant "+ Action" de l'éditeur de collision +// (voir static/js/scenes/collision-rules-editor.js) quand l'auteur +// choisit "Déclencher une quête" — créées dynamiquement (ensureQuest*Modal) +// plutôt que dupliquées dans deux endroits. // // Une quête (voir db/quests/) : id, title, objectif, recompense_score, // status (nouvelle/en_cours/terminee — statut COURANT, informatif), @@ -32,7 +34,7 @@ function questById(questId) { function questListRowHtml(quest) { var resultatHtml = quest.resultat ? '' + QUEST_RESULT_LABELS[quest.resultat] + '' : '—'; return ( - '' + + '' + '' + (quest.title || '').replace(/' + '' + (quest.objectif ? quest.objectif.replace(/—') + '' + '🏆 +' + (quest.recompense_score || 0) + '' + @@ -68,7 +70,7 @@ function createQuestFromListPage() { .then(function (json) { QUESTS.unshift(json.quest); renderQuestListPage(); - openQuestDialogueModal(json.quest.id); + openQuestFieldsModal(json.quest.id); }); } @@ -82,22 +84,28 @@ function questDeleteFromList(questId) { }); } -// ---------- Modale "arbre de dialogue" ---------- -// Créée une seule fois (au premier appel), réutilisée ensuite — partagée -// entre la page dédiée et l'assistant "+ Action" de l'éditeur de -// collision, jamais dupliquée dans les templates. +// ---------- Deux modales distinctes ---------- +// 1. #questFieldsModal (taille normale) : "je crée" — titre/objectif/ +// récompense/statut/résultat, + un bouton qui ouvre... +// 2. #questDialogueModal (PLEIN ÉCRAN) : "j'ajoute les dialogues" — les +// 3 colonnes de répliques, rien d'autre. Étapes séparées demandées +// explicitement par l'utilisateur (pas un seul gros formulaire). +// Créées une seule fois chacune (au premier appel), réutilisées ensuite — +// partagées avec l'assistant "+ Action" de l'éditeur de collision, +// jamais dupliquées dans les templates. var _questModalOnUse = null; +var _questModalCurrentId = null; -function ensureQuestDialogueModal() { - var modal = document.getElementById('questDialogueModal'); +function ensureQuestFieldsModal() { + var modal = document.getElementById('questFieldsModal'); if (modal) return modal; modal = document.createElement('div'); modal.className = 'modal'; - modal.id = 'questDialogueModal'; + modal.id = 'questFieldsModal'; modal.innerHTML = - '' + - '