From d5a59bdbd885b193add40b48ede216dd437413a3 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 06:21:13 +0200 Subject: [PATCH] =?UTF-8?q?Fusion=20des=20deux=20moteurs=20:=20type=20d'?= =?UTF-8?q?=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