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