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