From efa7d1d9b08b73e64317effe2a6022c269efaceb Mon Sep 17 00:00:00 2001 From: william Date: Mon, 31 Aug 2026 09:42:38 +0200 Subject: [PATCH] =?UTF-8?q?Fondations=20multi-=C3=A9diteurs=20(1/3)=20:=20?= =?UTF-8?q?game=5Ftype=20+=20sch=C3=A9ma/CRUD=20des=20objets=20de=20sc?= =?UTF-8?q?=C3=A8ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première brique du plan "Fondations d'une plateforme multi-éditeurs" — l'utilisateur veut un éditeur dédié aux jeux 2D/serious games (scène à coordonnées pixel fixes, objets en couches, collision, personnages animés), distinct de l'éditeur générique actuel, sans dupliquer ce qui peut être partagé (comptes, objets de données, moteur de logique de flow, hébergement/publication). - db/games/get_game_type.py (nouveau) : "document" (défaut, éditeur actuel) ou "jeu_2d" (nouveau), stocké dans _meta comme is_public_played — aucune migration pour les jeux déjà créés (retombent sur "document"). Choisi obligatoirement à la création (templates/index.html), jamais modifiable ensuite. - screens/scenes/ (nouveau sous-module) : table _scene_objects (une scène = des objets en pixels fixes, pas les % fluides de _screen_elements — indispensable pour la collision/l'animation), CRUD complet, rendu HTML. 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 d'animation, juste une autre table de stockage. - screens/flow/ensure_flow_schema.py : += trigger_object_id/ target_object_id (ALTER TABLE sans contrainte FK, même patron que cond_element_a) — le moteur de logique de flow (_flow_nodes/_flow_edges, flow-engine.js) reste EXACTEMENT le même pour les deux éditeurs, seule la palette de nœuds change (screens/scenes/flow_palette.py, nouveau : sous-ensemble direct des triggers/actions existants, déjà génériques). - screens/screens_repo/ensure_schema.py : += scene_width/scene_height sur _screens (taille de scène fixe en pixels, sans effet sur un écran "document"). Reste à faire (prochains commits) : route + template de l'éditeur de scène, puis le rendu en mode jouable. Co-Authored-By: Claude Sonnet 5 --- db/__init__.py | 3 +- db/games/create_game.py | 11 ++- db/games/game_meta.py | 5 ++ db/games/get_game_type.py | 17 ++++ routes/games/games_new.py | 5 +- screens/__init__.py | 10 +++ screens/flow/constants.py | 4 + screens/flow/ensure_flow_schema.py | 13 +++ screens/scenes/__init__.py | 3 + screens/scenes/add_scene_object.py | 41 +++++++++ screens/scenes/delete_scene_object.py | 24 +++++ screens/scenes/ensure_scene_schema.py | 30 +++++++ screens/scenes/flow_palette.py | 42 +++++++++ screens/scenes/get_scene_object.py | 17 ++++ screens/scenes/list_scene_objects.py | 20 +++++ screens/scenes/render_scene_object.py | 26 ++++++ .../set_scene_object_personnage_data.py | 25 ++++++ .../scenes/update_scene_object_geometry.py | 18 ++++ screens/screens_repo/ensure_schema.py | 9 ++ templates/index.html | 10 +++ tests/test_game_type.py | 38 ++++++++ tests/test_scene_objects.py | 87 +++++++++++++++++++ 22 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 db/games/get_game_type.py create mode 100644 screens/scenes/__init__.py create mode 100644 screens/scenes/add_scene_object.py create mode 100644 screens/scenes/delete_scene_object.py create mode 100644 screens/scenes/ensure_scene_schema.py create mode 100644 screens/scenes/flow_palette.py create mode 100644 screens/scenes/get_scene_object.py create mode 100644 screens/scenes/list_scene_objects.py create mode 100644 screens/scenes/render_scene_object.py create mode 100644 screens/scenes/set_scene_object_personnage_data.py create mode 100644 screens/scenes/update_scene_object_geometry.py create mode 100644 tests/test_game_type.py create mode 100644 tests/test_scene_objects.py diff --git a/db/__init__.py b/db/__init__.py index 41be883d..a6a19fc2 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -31,6 +31,7 @@ from .games.list_games import list_games from .games.game_meta import game_meta from .games.is_public_played import is_public_played from .games.set_public_played import set_public_played +from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE from .games.create_game import create_game from .games.update_game_name import update_game_name from .games.delete_game import delete_game @@ -74,7 +75,7 @@ __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "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", + "is_public_played", "set_public_played", "get_game_type", "DEFAULT_GAME_TYPE", "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/create_game.py b/db/games/create_game.py index 055787c7..0500c7fe 100644 --- a/db/games/create_game.py +++ b/db/games/create_game.py @@ -3,9 +3,10 @@ import os from ..connection import connect from ..game_dir import game_dir from ..slugify import slugify +from .get_game_type import DEFAULT_GAME_TYPE -def create_game(name, slug_override=None): +def create_game(name, slug_override=None, game_type=DEFAULT_GAME_TYPE): """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). @@ -13,7 +14,12 @@ def create_game(name, slug_override=None): `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à.""" + -2/-3...) si, par un hasard extrême, le dossier existe déjà. + + 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 n = 2 @@ -78,6 +84,7 @@ 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/game_meta.py b/db/games/game_meta.py index ab6de535..38043ae6 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -1,5 +1,6 @@ from ..connection import connect from .is_public_played import is_public_played +from .get_game_type import get_game_type def game_meta(slug): @@ -13,4 +14,8 @@ 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), } diff --git a/db/games/get_game_type.py b/db/games/get_game_type.py new file mode 100644 index 00000000..ad1a1d1e --- /dev/null +++ b/db/games/get_game_type.py @@ -0,0 +1,17 @@ +from ..connection import connect + +DEFAULT_GAME_TYPE = "document" + + +def get_game_type(slug): + """Type de jeu choisi à la création (voir create_game.py) : "document" + (éditeur générique actuel — quiz/formulaires/contenus, écrans = + éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié — + voir screens/scenes/, écrans = objets pixel-positionnés en couches). + Réutilise _meta comme is_public_played — aucune nouvelle table. Un jeu + créé avant l'existence de cette clé (absente de _meta) retombe sur + "document", son comportement d'avant, inchangé.""" + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone() + conn.close() + return row["value"] if row and row["value"] else DEFAULT_GAME_TYPE diff --git a/routes/games/games_new.py b/routes/games/games_new.py index 314198b8..a45fa912 100644 --- a/routes/games/games_new.py +++ b/routes/games/games_new.py @@ -10,5 +10,8 @@ def games_new(): name = request.form.get("name", "").strip() if not name: return redirect(url_for("index")) - slug = db.create_game(name) + 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) return redirect(url_for("game_dashboard", slug=slug)) diff --git a/screens/__init__.py b/screens/__init__.py index 2f522f2e..93a75a2c 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -89,6 +89,16 @@ from .elements.flow_nodes_referencing_element import flow_nodes_referencing_elem from .elements.duplicate_element import duplicate_element from .elements.rename_element import rename_element from .elements.set_personnage_data import set_personnage_data + +from .scenes.ensure_scene_schema import ensure_scene_schema +from .scenes.add_scene_object import add_scene_object +from .scenes.list_scene_objects import list_scene_objects +from .scenes.get_scene_object import get_scene_object +from .scenes.update_scene_object_geometry import update_scene_object_geometry +from .scenes.delete_scene_object import delete_scene_object +from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data +from .scenes.render_scene_object import render_scene_object +from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES from .elements.element_label import element_label from .element_types.list_element_types import list_element_types diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 17fed736..bb7bf9cd 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -123,4 +123,8 @@ FLOW_NODE_FIELDS = { # rectangles — voir evaluateConditionClause() dans # static/js/play/conditions.js. "cond_element_a", "cond_element_b", + # Fondations multi-éditeurs — jeux "jeu_2d" : équivalents de + # trigger_element_id/target_element_id pour un objet de scène (voir + # ensure_flow_schema.py). + "trigger_object_id", "target_object_id", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index 8bf739ec..f776bf3e 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -116,5 +116,18 @@ def ensure_flow_schema(slug): conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_element_a INTEGER") if "cond_element_b" not in cols: conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_element_b INTEGER") + if "trigger_object_id" not in cols: + # Fondations multi-éditeurs — jeux "jeu_2d" (voir db/games/ + # get_game_type.py) : équivalents de trigger_element_id/ + # target_element_id, mais pour un objet de _scene_objects plutôt + # qu'un élément DOM (_screen_elements). Pas de contrainte FK, même + # patron que cond_element_a ci-dessus (nettoyage manuel à la + # suppression d'un objet de scène, voir screens/scenes/) — ces deux + # colonnes ne pouvaient pas réutiliser trigger_element_id/ + # target_element_id, qui portent une VRAIE contrainte FK vers + # _screen_elements depuis la création de cette table. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_object_id INTEGER") + if "target_object_id" not in cols: + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_object_id INTEGER") conn.commit() conn.close() diff --git a/screens/scenes/__init__.py b/screens/scenes/__init__.py new file mode 100644 index 00000000..43b11d3e --- /dev/null +++ b/screens/scenes/__init__.py @@ -0,0 +1,3 @@ +"""Objets posés sur une scène (_scene_objects) — jeux "jeu_2d" (voir +db/games/get_game_type.py), équivalent de screens/elements/ pour l'éditeur +document mais en coordonnées pixel fixes.""" diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py new file mode 100644 index 00000000..18077cb1 --- /dev/null +++ b/screens/scenes/add_scene_object.py @@ -0,0 +1,41 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + +_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"} +_KINDS = {"personnage", "decor"} + + +def add_scene_object(slug, scene_id, kind="personnage", forge_character=None): + """Pose un nouvel objet sur une scène (voir ensure_scene_schema.py). + kind="personnage" : attributes._personnage_data, MÊME structure/mêmes + fonctions resolve_personnage_* que le widget "personnage" de l'éditeur + document (screens/rendering/personnage_data.py) — un objet de scène + "personnage" partage donc directement la bibliothèque Forge et le + système d'animation déjà écrits. kind="decor" : une simple image fixe + (attributes.src), pas d'animation.""" + ensure_scene_schema(slug) + kind = kind if kind in _KINDS else "personnage" + attributes = {} + if kind == "personnage": + data = dict(_DEFAULT_PERSONNAGE_DATA) + if forge_character: + from ..labels.sprite_library import SPRITE_LIBRARY + if forge_character in SPRITE_LIBRARY: + data["forge_character"] = forge_character + attributes["_personnage_data"] = json.dumps(data) + conn = db.connect(slug) + max_z = conn.execute( + "SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,) + ).fetchone()["m"] + conn.execute( + """INSERT INTO _scene_objects (scene_id, kind, attributes, z_index) + VALUES (?, ?, ?, ?)""", + (scene_id, kind, json.dumps(attributes), (max_z or 0) + 1), + ) + object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return object_id diff --git a/screens/scenes/delete_scene_object.py b/screens/scenes/delete_scene_object.py new file mode 100644 index 00000000..14ae886e --- /dev/null +++ b/screens/scenes/delete_scene_object.py @@ -0,0 +1,24 @@ +import db + +from .ensure_scene_schema import ensure_scene_schema +from ..flow.ensure_flow_schema import ensure_flow_schema + + +def delete_scene_object(slug, object_id): + """Supprime un objet de scène ET les nœuds de logique qui le + référencent (trigger_object_id/target_object_id/cond_element_a/ + cond_element_b — aucun n'a de contrainte FK, voir + ensure_flow_schema.py) : un nœud qui vise un objet supprimé n'a plus + de sens, même patron que screens/elements/delete_element.py pour le + DOM.""" + ensure_scene_schema(slug) + ensure_flow_schema(slug) + conn = db.connect(slug) + conn.execute( + """DELETE FROM _flow_nodes WHERE trigger_object_id = ? OR target_object_id = ? + OR cond_element_a = ? OR cond_element_b = ?""", + (object_id, object_id, object_id, object_id), + ) + conn.execute("DELETE FROM _scene_objects WHERE id = ?", (object_id,)) + conn.commit() + conn.close() diff --git a/screens/scenes/ensure_scene_schema.py b/screens/scenes/ensure_scene_schema.py new file mode 100644 index 00000000..17616e7b --- /dev/null +++ b/screens/scenes/ensure_scene_schema.py @@ -0,0 +1,30 @@ +import db + + +def ensure_scene_schema(slug): + """Crée _scene_objects si nécessaire — le contenu d'une scène pour un + jeu "jeu_2d" (voir db/games/get_game_type.py), équivalent de + _screen_elements pour un jeu "document" mais en coordonnées PIXEL + fixes (pas des % fluides — indispensable pour la collision/ + l'animation), sans arbre de nesting (pas de conteneurs imbriqués dans + une scène de jeu 2D).""" + conn = db.connect(slug) + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS _scene_objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scene_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'personnage', + name TEXT, + x REAL NOT NULL DEFAULT 100, + y REAL NOT NULL DEFAULT 100, + width REAL NOT NULL DEFAULT 64, + height REAL NOT NULL DEFAULT 64, + z_index INTEGER NOT NULL DEFAULT 1, + attributes TEXT NOT NULL DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/screens/scenes/flow_palette.py b/screens/scenes/flow_palette.py new file mode 100644 index 00000000..9df49eba --- /dev/null +++ b/screens/scenes/flow_palette.py @@ -0,0 +1,42 @@ +# ---------- Palette de logique réduite pour l'éditeur de scène 2D ---------- +# +# Réutilise le MÊME moteur de flow (_flow_nodes/_flow_edges, flow-engine.js, +# flow-editor.js, tabs-and-blocks.js) que l'éditeur document — seule la +# palette de nœuds proposée diffère (voir décision d'architecture du plan +# "Fondations d'une plateforme multi-éditeurs"). Sous-ensembles directs de +# screens/flow/constants.py et screens/labels/action_type_labels.py : pas +# de notion de clic/soumission/survol DOM (pas d'élément HTML dans une +# scène), tout le reste est déjà générique et fonctionne tel quel sur un +# objet de scène. +from ..flow.constants import TRIGGER_EVENTS as _ALL_TRIGGER_EVENTS + +_SCENE_TRIGGER_KEYS = {"affichage", "evenement", "clavier", "minuteur"} +TRIGGER_EVENTS_2D = [(k, label) for k, label in _ALL_TRIGGER_EVENTS if k in _SCENE_TRIGGER_KEYS] + +ACTION_TYPE_LABELS_2D = { + "aller_a": "Aller à un écran précis", + "ecran_suivant": "Afficher l'écran suivant", + "ecran_precedent": "Afficher l'écran précédent", + "modifier_objet_scene": "Modifier un objet de scène", + "modifier_variable": "Modifier une variable globale", + "modifier_donnee": "Modifier une donnée d'un objet", + "jouer_animation_sprite": "Jouer une animation de sprites", + "jouer_son": "Jouer un son", + "declencher_evenement": "Déclencher un événement", + "attendre": "Attendre quelques secondes avant de continuer", + "rien": "Ne rien faire", +} + +# Sous-palette de "modifier_objet_scene" — équivalent scène de +# ELEMENT_ACTION_PROPERTIES (screens/labels/element_action_properties.py), +# mais en pixels absolus/relatifs (pas des %) : une scène a une taille +# FIXE (voir scene_width/scene_height, ensure_schema.py), donc les +# déplacements s'expriment naturellement en pixels. +OBJECT_ACTION_PROPERTIES = [ + ("visibilite", "Visibilité"), + ("pos_x", "Position horizontale en pixels (absolue)"), + ("pos_y", "Position verticale en pixels (absolue)"), + ("pos_x_relatif", "Déplacer horizontalement de... px (relatif)"), + ("pos_y_relatif", "Déplacer verticalement de... px (relatif)"), + ("orientation", "Orientation — sens du personnage"), +] diff --git a/screens/scenes/get_scene_object.py b/screens/scenes/get_scene_object.py new file mode 100644 index 00000000..f94520d2 --- /dev/null +++ b/screens/scenes/get_scene_object.py @@ -0,0 +1,17 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def get_scene_object(slug, object_id): + ensure_scene_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _scene_objects WHERE id = ?", (object_id,)).fetchone() + conn.close() + if not row: + return None + d = dict(row) + d["attributes"] = json.loads(d["attributes"] or "{}") + return d diff --git a/screens/scenes/list_scene_objects.py b/screens/scenes/list_scene_objects.py new file mode 100644 index 00000000..0b790420 --- /dev/null +++ b/screens/scenes/list_scene_objects.py @@ -0,0 +1,20 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def list_scene_objects(slug, scene_id): + ensure_scene_schema(slug) + conn = db.connect(slug) + rows = conn.execute( + "SELECT * FROM _scene_objects WHERE scene_id = ? ORDER BY z_index, id", (scene_id,) + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + d["attributes"] = json.loads(d["attributes"] or "{}") + result.append(d) + return result diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py new file mode 100644 index 00000000..e098b06b --- /dev/null +++ b/screens/scenes/render_scene_object.py @@ -0,0 +1,26 @@ +import html as html_lib + +from ..rendering.personnage_data import resolve_personnage_idle_frame + + +def render_scene_object(obj): + """HTML d'un objet de scène — positionnement en PIXELS ABSOLUS (pas de + % fluide, voir ensure_scene_schema.py), mirror volontairement plus + simple que render_element_html.py (pas d'arbre de nesting, pas de + palette de widgets à gérer) : juste kind="personnage" (réutilise + resolve_personnage_idle_frame, même _personnage_data que le widget + "personnage" de l'éditeur document) ou kind="decor" (image fixe, + attributes.src). data-object-id/data-personnage jouent le même rôle + que data-element-id/data-personnage côté DOM (voir + static/js/screen_edit/personnage-preview.js, static/js/play/actions.js).""" + style = ( + f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; " + f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']};" + ) + if obj["kind"] == "personnage": + src = resolve_personnage_idle_frame(obj) + src_attr = f' src="{html_lib.escape(src)}"' if src else "" + return f'' + src = (obj.get("attributes") or {}).get("src", "") + src_attr = f' src="{html_lib.escape(src)}"' if src else "" + return f'' diff --git a/screens/scenes/set_scene_object_personnage_data.py b/screens/scenes/set_scene_object_personnage_data.py new file mode 100644 index 00000000..fb318ac5 --- /dev/null +++ b/screens/scenes/set_scene_object_personnage_data.py @@ -0,0 +1,25 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_personnage_data(slug, object_id, data): + """Change le personnage Forge d'un objet de scène kind="personnage", + ou met à jour ses animations — mirror exact de + screens/elements/set_personnage_data.py, même clé _personnage_data + (même structure, réutilisée telle quelle par resolve_personnage_*, + voir screens/rendering/personnage_data.py), juste sur _scene_objects + au lieu de _screen_elements.""" + 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_data"] = json.dumps(data) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/screens/scenes/update_scene_object_geometry.py b/screens/scenes/update_scene_object_geometry.py new file mode 100644 index 00000000..f6460cf3 --- /dev/null +++ b/screens/scenes/update_scene_object_geometry.py @@ -0,0 +1,18 @@ +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def update_scene_object_geometry(slug, object_id, x, y, width, height): + """Position/taille en PIXELS (pas des %, voir ensure_scene_schema.py) + — utilisé par le glisser-déposer/redimensionnement de l'éditeur de + scène, mirror exact de update_element_geometry.py (éditeur document) : + ne touche qu'à la géométrie, rien d'autre.""" + ensure_scene_schema(slug) + conn = db.connect(slug) + conn.execute( + "UPDATE _scene_objects SET x = ?, y = ?, width = ?, height = ? WHERE id = ?", + (x, y, width, height, object_id), + ) + conn.commit() + conn.close() diff --git a/screens/screens_repo/ensure_schema.py b/screens/screens_repo/ensure_schema.py index 6ea6a26c..92190d60 100644 --- a/screens/screens_repo/ensure_schema.py +++ b/screens/screens_repo/ensure_schema.py @@ -114,6 +114,15 @@ def ensure_schema(slug): # Indépendante de l'action "Jouer un son" (effet sonore ponctuel, # non bouclé, sur n'importe quel déclencheur). conn.execute("ALTER TABLE _screens ADD COLUMN background_music_url TEXT") + if "scene_width" not in screen_cols: + # Fondations multi-éditeurs — jeux "jeu_2d" (voir db/games/ + # get_game_type.py) : une scène a une taille FIXE en pixels + # (contrairement à un écran "document", positionné en % fluides + # via preview_aspect ci-dessus) — indispensable pour la collision + # et l'animation de sprites. Sans effet sur un écran "document" + # (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") 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/templates/index.html b/templates/index.html index 9c22214a..662969d5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,6 +16,16 @@ + +
+ + +

Crée un dossier avec index.html / index.css / index.js reliés entre eux, et une base de données dédiée à ce jeu.

diff --git a/tests/test_game_type.py b/tests/test_game_type.py new file mode 100644 index 00000000..97839409 --- /dev/null +++ b/tests/test_game_type.py @@ -0,0 +1,38 @@ +"""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").""" +import db + + +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_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_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) + 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) + + +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) + 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) diff --git a/tests/test_scene_objects.py b/tests/test_scene_objects.py new file mode 100644 index 00000000..688be390 --- /dev/null +++ b/tests/test_scene_objects.py @@ -0,0 +1,87 @@ +"""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" +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 +d'animation côté client, juste une autre table de stockage.""" +import db +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") + return slug, screen_id + + +def test_scene_object_defaults_to_forge_personnage(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id) + obj = screens.get_scene_object(slug, obj_id) + assert obj["kind"] == "personnage" + assert obj["attributes"]["_personnage_data"] + data = screens.resolve_personnage_data(obj) + assert data["source"] == "forge" + assert data["forge_character"] in screens.SPRITE_LIBRARY + + +def test_add_scene_object_with_specific_forge_character(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id, kind="personnage", forge_character="zombie") + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_personnage_data(obj)["forge_character"] == "zombie" + + +def test_add_decor_scene_object(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id, kind="decor") + obj = screens.get_scene_object(slug, obj_id) + assert obj["kind"] == "decor" + + +def test_scene_object_geometry_is_pixels_not_percent(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id) + screens.update_scene_object_geometry(slug, obj_id, x=320, y=200, width=128, height=128) + obj = screens.get_scene_object(slug, obj_id) + assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (320, 200, 128, 128) + + +def test_list_scene_objects_ordered_by_z_index(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + first = screens.add_scene_object(slug, screen_id) + second = screens.add_scene_object(slug, screen_id) + objs = screens.list_scene_objects(slug, screen_id) + assert [o["id"] for o in objs] == [first, second] + + +def test_swap_scene_object_forge_character(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id, forge_character="male-adventurer") + screens.set_scene_object_personnage_data(slug, obj_id, {"source": "forge", "forge_character": "robot"}) + obj = screens.get_scene_object(slug, obj_id) + assert screens.resolve_personnage_data(obj)["forge_character"] == "robot" + + +def test_render_scene_object_shows_idle_frame_pixel_positioned(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie") + screens.update_scene_object_geometry(slug, obj_id, x=50, y=60, width=64, height=64) + obj = screens.get_scene_object(slug, obj_id) + html = screens.render_scene_object(obj) + assert "/static/characters/zombie/idle.png" in html + assert "left:50" in html and "top:60" in html + assert f'data-object-id="{obj_id}"' in html + assert 'data-personnage="1"' in html + + +def test_delete_scene_object_cleans_up_referencing_flow_nodes(tmp_game_slug_cleanup): + slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id) + node_id = screens.add_flow_node( + slug, screen_id, node_type="action", action_type="jouer_animation_sprite", + target_object_id=obj_id, data_value='{"animation":"idle","fps":8,"loop":true}', + ) + screens.delete_scene_object(slug, obj_id) + assert screens.get_flow_node(slug, node_id) is None