diff --git a/.gitignore b/.gitignore index e3261c38..4b2ac312 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,11 @@ data/ # qui les convertit en static/characters/animals/ (ça, c'est committé). assets/characters/ +# Même règle que ci-dessus, pour les images de fond CraftPix — voir +# scripts/generate_background_manifest.py, qui les convertit en +# static/backgrounds/ (ça, c'est committé). +assets/background/ + # OS / éditeurs .DS_Store Thumbs.db diff --git a/core/auth_guard.py b/core/auth_guard.py index 5f60bbd7..add41e39 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -19,19 +19,19 @@ from .flask_app import app # Endpoints accessibles SANS être connecté — tout le reste exige une # session valide. "static" (CSS/JS/images) doit rester public : la page # de connexion elle-même en a besoin pour s'afficher. -# -# game_play_public/runtime_payload_public/flow_node_run_data_public/ -# flow_node_run_variable_public (routes/public_play/) : la route PUBLIQUE -# /jouer/, pour un vrai visiteur anonyme (état par joueur, Phase 1) -# — chaque vue vérifie ELLE-MÊME db.is_public_played(slug) (404 sinon), -# cette garde générique ne fait qu'autoriser l'accès sans connexion, elle -# ne dispense d'aucune autre vérification. _PUBLIC_ENDPOINTS = { "static", "login", "login_2fa", "register", "register_2fa", "logout", "forgot_password", "reset_password", - "game_play_public", "runtime_payload_public", - "flow_node_run_data_public", "flow_node_run_variable_public", - "flow_node_run_add_row_public", +} + +# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion +# requise, mais indépendants d'avoir un projet ou non (voir onboarding +# guidé ci-dessous) : un compte pas encore passé par /onboarding doit +# quand même pouvoir changer son mot de passe, son email, ou supprimer +# son compte. +_REACHABLE_WITHOUT_PROJECT = { + "onboarding_new", "profile", "profile_update_name", "profile_update_email", + "profile_regenerate_recovery_codes", "profile_change_password", "profile_delete", } @@ -61,9 +61,22 @@ def _require_login_and_enforce_project_isolation(): if user["role"] != "admin": if endpoint in ("index", "games_new") and user.get("project_slug"): return redirect(url_for("game_dashboard", slug=user["project_slug"])) + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un + # compte "user" fraîchement inscrit n'a plus de projet créé + # d'office (voir routes/auth/register_2fa.py) — tant qu'il n'en a + # pas, seules ces routes restent atteignables (toutes exigent déjà + # une connexion, absentes de _PUBLIC_ENDPOINTS). + if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT: + return redirect(url_for("onboarding_new")) slug = request.view_args.get("slug") if request.view_args else None if slug is not None and slug != user.get("project_slug"): abort(403) + # Type d'onboarding "restreint" (quiz/embranchement/rpg, voir + # db/games/game_type_catalog.py) : game_dashboard reste atteignable + # comme pour "custom", mais routes/games/game_dashboard.py y rend + # une version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/ + # Éléments de jeu/Variables) — rien à bloquer ici, la restriction + # se fait au niveau du rendu, pas de l'accès à la route. if endpoint == "game_delete": # Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en # recréer un une fois supprimé (games_new le renvoie toujours diff --git a/core/player_identity.py b/core/player_identity.py deleted file mode 100644 index d1639c71..00000000 --- a/core/player_identity.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Identité d'un visiteur anonyme de la route publique /jouer/ (voir -routes/public_play/) — état par joueur, Phase 1. Un cookie NON SIGNÉ (pas -besoin de itsdangerous/app.secret_key ici) : c'est une simple clé de -PARTITION des données (quelles lignes/valeurs appartiennent à ce visiteur), -jamais un jeton d'autorisation — la contrefaçon la plus grave possible -serait de se faire passer pour un AUTRE joueur anonyme du même jeu, pas de -franchir une frontière de sécurité (l'aperçu créateur, lui, reste protégé -par core/auth_guard.py, complètement indépendant de ce mécanisme).""" -import secrets - -from flask import request - -PLAYER_ID_COOKIE = "forge_player_id" -_PLAYER_ID_MAX_AGE = 60 * 60 * 24 * 365 # 1 an - - -def get_or_create_player_id(): - """Renvoie (player_id, is_new) — is_new indique s'il faut poser le - cookie sur la réponse avant de la renvoyer (voir - set_player_id_cookie ci-dessous) : à utiliser aussi bien depuis une - route qui rend une page (GET /jouer/) qu'une route API JSON - (runtime-payload, run-data, run-variable), dont la réponse n'est - construite qu'APRÈS avoir déjà besoin du player_id pour agir.""" - existing = request.cookies.get(PLAYER_ID_COOKIE) - if existing: - return existing, False - return secrets.token_urlsafe(16), True - - -def set_player_id_cookie(response, player_id): - response.set_cookie( - PLAYER_ID_COOKIE, player_id, - max_age=_PLAYER_ID_MAX_AGE, httponly=True, samesite="Lax", - ) - return response diff --git a/core/sprite_gate.py b/core/sprite_gate.py index 280e58e6..de8e05b1 100644 --- a/core/sprite_gate.py +++ b/core/sprite_gate.py @@ -14,3 +14,13 @@ import screens def forbid_admin_only_character(forge_character): if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin": abort(403) + + +# Même règle que forbid_admin_only_character ci-dessus, pour la +# bibliothèque d'images de fond CraftPix (screens.BACKGROUND_LIBRARY, +# voir screens/labels/background_library.py) — TOUJOURS admin-only +# aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement +# aux personnages Forge/Kenney (domaine public, jamais gatés). +def forbid_admin_only_background(background_slug): + if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin": + abort(403) diff --git a/db/__init__.py b/db/__init__.py index a6a19fc2..9d49d31f 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un vrai INSERT dans cette table. """ -from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES +from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS from .slugify import slugify from .table_name_for import table_name_for from .game_dir import game_dir @@ -29,13 +29,16 @@ from .connection import connect 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 from .games.move_game import move_game +from .games.game_type_catalog import ( + ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, + get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, + is_restricted, +) from .definitions.list_definitions import list_definitions from .definitions.get_definition import get_definition @@ -66,16 +69,34 @@ from .global_vars.update_global_variable import update_global_variable from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id +from .scoring.get_score import get_score +from .scoring.set_score_value import set_score_value +from .scoring.set_status import set_status + from .custom_events.list_custom_events import list_custom_events from .custom_events.get_custom_event import get_custom_event from .custom_events.create_custom_event import create_custom_event from .custom_events.update_custom_event import update_custom_event +from .quests.constants import ( + QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES, +) +from .quests.list_quests import list_quests +from .quests.get_quest import get_quest +from .quests.create_quest import create_quest +from .quests.update_quest import update_quest +from .quests.delete_quest import delete_quest +from .quests.set_quest_dialogues import set_quest_dialogues +from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues + __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", + "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", "slugify", "table_name_for", "game_dir", "db_path", "connect", "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", - "is_public_played", "set_public_played", "get_game_type", "DEFAULT_GAME_TYPE", + "get_game_type", "DEFAULT_GAME_TYPE", + "ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", + "get_onboarding_type_raw", "set_onboarding_type", "is_restricted", "list_definitions", "get_definition", "create_definition", "rename_definition", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "delete_definition", @@ -85,4 +106,7 @@ __all__ = [ "update_global_variable_value", "update_global_variable", "delete_global_variable", "delete_global_variable_by_id", "list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", + "QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES", + "list_quests", "get_quest", "create_quest", "update_quest", "delete_quest", + "set_quest_dialogues", "sanitize_quest_dialogues", ] diff --git a/db/constants.py b/db/constants.py index 4572394c..5eead8a7 100644 --- a/db/constants.py +++ b/db/constants.py @@ -3,13 +3,9 @@ import os # Surchargeable via FORGE_PROJECTS_DIR (même schéma que -# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur -# joueur autonome (publish/player_app_template.py), qui pose cette -# variable AVANT d'importer db/ pour pointer vers son propre dossier -# "projects/" embarqué plutôt que celui, réel, du poste de développement. -# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable -# d'environnement doit donc déjà être posée avant le tout premier -# `import db`. +# auth/connection.py::FORGE_USERS_DB_PATH). Résolu ici, à l'IMPORT (pas +# par un appel de fonction) : la variable d'environnement doit donc déjà +# être posée avant le tout premier `import db`. PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects" ) @@ -41,3 +37,17 @@ GLOBAL_VARIABLE_TYPES = { "objet": {"label": "Objet (JSON)"}, "tableau": {"label": "Tableau (JSON)"}, } + +# Statuts de partie (voir db/scoring/) — préalable aux exports SCORM/xAPI, +# qui ont besoin d'un signal "terminé"/"réussi" propre plutôt que d'une +# convention sur une variable choisie par le créateur (voir le plan +# "Feuille de route produit"). "non_commence" est le défaut d'un joueur +# qui n'a encore rien écrit (voir db/scoring/get_score.py). +SCORE_STATUS_CHOICES = [ + ("non_commence", "Non commencée"), + ("en_cours", "En cours"), + ("termine", "Terminée"), + ("reussi", "Réussie"), + ("echoue", "Échouée"), +] +SCORE_STATUS_LABELS = dict(SCORE_STATUS_CHOICES) diff --git a/db/game_dir.py b/db/game_dir.py index bfc8d731..921a1bc3 100644 --- a/db/game_dir.py +++ b/db/game_dir.py @@ -1,7 +1,15 @@ import os from .constants import PROJECTS_DIR +from .games.project_slug import split_slug def game_dir(slug): - return os.path.join(PROJECTS_DIR, slug) + # Structure de dossiers par utilisateur (voir db/games/project_slug.py) + # : un slug composé "propriétaire_projet" résout vers un vrai chemin + # imbriqué projects/// — repli sur l'ancien + # comportement plat pour un slug pas encore migré (project_part=None). + owner_folder, project_part = split_slug(slug) + if project_part is None: + return os.path.join(PROJECTS_DIR, slug) + return os.path.join(PROJECTS_DIR, owner_folder, project_part) diff --git a/db/games/create_game.py b/db/games/create_game.py index 0500c7fe..9dd29eeb 100644 --- a/db/games/create_game.py +++ b/db/games/create_game.py @@ -3,30 +3,43 @@ import os from ..connection import connect from ..game_dir import game_dir from ..slugify import slugify -from .get_game_type import DEFAULT_GAME_TYPE +from .project_slug import build_slug -def create_game(name, slug_override=None, game_type=DEFAULT_GAME_TYPE): +def create_game(name, owner_folder=None, project_slug_override=None): """Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js reliés entre eux, et sa base de données dédiée (nom du jeu en méta). - slug_override : impose le nom du dossier au lieu de le dériver de - `name` — utilisé par l'inscription (auth/) pour que le dossier d'un - compte porte son adresse email (slugifiée), indépendamment du nom - donné à son jeu. Toujours rendu unique de la même façon (suffixe - -2/-3...) si, par un hasard extrême, le dossier existe déjà. + owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py — + structure de dossiers par utilisateur), typiquement + slugify(email_du_compte) — le slug final composé + "_" résout vers un vrai chemin imbriqué + projects///. Omis (None) : repli sur + l'ancien comportement plat (slug à un seul segment, projects//) + — utile pour les tests de la couche données qui n'ont pas de compte + utilisateur à représenter. - game_type : "document" (défaut, éditeur générique actuel) ou "jeu_2d" - (éditeur de scène — voir get_game_type.py) — choisi une fois pour - toutes à la création, gate quel éditeur/quel rendu jouable ce jeu - utilise (voir routes/screens/screen_edit.py).""" - slug = slugify(slug_override) if slug_override else slugify(name) - base = slug + project_slug_override : impose le nom du dossier PROJET au lieu de le + dériver de `name` — utilisé par l'inscription (auth/, voir + routes/onboarding/) pour que le tout premier projet d'un compte porte + un nom de dossier prévisible. Le suffixe d'unicité (-2/-3...) ne + compare que parmi les projets déjà présents dans CE `owner_folder` + (deux comptes différents peuvent donc avoir chacun un projet nommé + pareil, sans collision). + + Un projet n'a plus de "type" propre (voir screens/screens_repo/ + ensure_schema.py — fusion des moteurs, "document"/"jeu_2d" est + désormais une propriété PAR ÉCRAN) : un projet fraîchement créé n'a + par défaut aucun écran, c'est à l'appelant d'en créer un premier avec + le "kind" voulu (voir screens.create_screen).""" + project_part = slugify(project_slug_override) if project_slug_override else slugify(name) + base = project_part n = 2 - while os.path.isdir(game_dir(base)): - base = f"{slug}-{n}" + while os.path.isdir(game_dir(build_slug(owner_folder, base) if owner_folder else base)): + base = f"{project_part}-{n}" n += 1 - slug = base + project_part = base + slug = build_slug(owner_folder, project_part) if owner_folder else project_part os.makedirs(game_dir(slug), exist_ok=True) @@ -84,7 +97,6 @@ console.log("Jeu chargé."); """ ) conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,)) - conn.execute("INSERT INTO _meta (key, value) VALUES ('game_type', ?)", (game_type,)) conn.commit() conn.close() return slug diff --git a/db/games/delete_game.py b/db/games/delete_game.py index 48be17e9..afa8bf4c 100644 --- a/db/games/delete_game.py +++ b/db/games/delete_game.py @@ -1,7 +1,20 @@ +import os import shutil +from ..constants import PROJECTS_DIR from ..game_dir import game_dir +from .project_slug import split_slug def delete_game(slug): shutil.rmtree(game_dir(slug)) + # Structure de dossiers par utilisateur (voir project_slug.py) : + # nettoie aussi le dossier propriétaire s'il ne contient plus aucun + # projet — purement cosmétique (game_dir()/list_games() fonctionnent + # très bien avec un dossier propriétaire vide), évite juste + # d'accumuler des dossiers fantômes sous projects/. + owner_folder, project_part = split_slug(slug) + if project_part is not None: + owner_path = os.path.join(PROJECTS_DIR, owner_folder) + if os.path.isdir(owner_path) and not os.listdir(owner_path): + os.rmdir(owner_path) diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 38043ae6..55bade7d 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -1,6 +1,5 @@ from ..connection import connect -from .is_public_played import is_public_played -from .get_game_type import get_game_type +from .game_type_catalog import get_onboarding_type def game_meta(slug): @@ -9,13 +8,11 @@ def game_meta(slug): conn.close() return { "slug": slug, "name": row["value"] if row else slug, - # État par joueur (Phase 1) : disponible partout où `game` est - # dans le contexte du template (base.html, la bascule "Publier en - # ligne" en a besoin pour afficher son état actuel), sans avoir à - # appeler is_public_played(slug) séparément dans chaque route. - "is_public_played": is_public_played(slug), - # Type de jeu (fondations multi-éditeurs) : "document" (éditeur - # générique) ou "jeu_2d" (éditeur de scène) — voir - # get_game_type.py et routes/screens/screen_edit.py. - "game_type": get_game_type(slug), + # Onboarding guidé (voir game_type_catalog.py) : utilisé par + # templates/base.html pour cacher le lien "Tableau de bord" à un + # compte "restreint" (quiz/embranchement/rpg). + "onboarding_type": get_onboarding_type(slug), + # Pas de "game_type" ici : depuis la fusion des moteurs (voir + # screens/screens_repo/ensure_schema.py), "document"/"jeu_2d" est + # une propriété PAR ÉCRAN (screen["kind"]), plus du projet. } diff --git a/db/games/game_type_catalog.py b/db/games/game_type_catalog.py new file mode 100644 index 00000000..b4308b73 --- /dev/null +++ b/db/games/game_type_catalog.py @@ -0,0 +1,98 @@ +"""Onboarding guidé (voir routes/onboarding/onboarding_new.py) : les 4 +choix proposés à CHAQUE création de projet — PAS un 3e type de moteur +(voir screens/screens_repo/ensure_schema.py, fusion des moteurs : +"document"/"jeu_2d" est une propriété PAR ÉCRAN), une classification +produit posée par-dessus. Chaque choix décide du "kind" du tout premier +écran créé (screen_kind ci-dessous, aussi imposé à tout écran ajouté +ensuite — voir routes/screens/screens_new.py) et si le tableau de bord +complet (game_dashboard, objets/éléments de jeu/variables) reste +accessible ensuite, ou seulement sa version simplifiée (juste les écrans +en cartes — voir routes/games/game_dashboard.py, restricted). + +_meta['onboarding_type'] (une ligne _meta par projet, même convention que +game_type) retient CE choix — jamais lu par le +rendu jouable, seulement par le routage (routes/games/game_dashboard.py, +routes/screens/screens_new.py).""" +import db + +DEFAULT_ONBOARDING_TYPE = "custom" + +ONBOARDING_TYPES = { + "quiz": { + "label": "Quiz", + "tagline": "Questions, réponses, score.", + "icon": "🎯", + "description": "Gérer des questions/réponses, avec score.", + "screen_kind": "document", + "restricted": True, + }, + "embranchement": { + "label": "Jeu à embranchement / escape game", + "tagline": "Chaque choix a une conséquence.", + "icon": "🗝️", + "description": ( + "Former à travers le jeu sur des concepts métier avec des " + "conséquences observables ; réaliser des escape games sous " + "forme d'énigmes." + ), + "screen_kind": "document", + "restricted": True, + }, + "rpg": { + "label": "Jeu RPG", + "tagline": "Ton monde, ton héros, ton scénario.", + "icon": "🗺️", + "description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.", + "screen_kind": "jeu_2d", + "restricted": True, + }, + "custom": { + "label": "Créer mon jeu de A à Z", + "tagline": "Toute la puissance du moteur, sans limite.", + "icon": "⚡", + "description": ( + "Pour les utilisateurs avec des notions de dev/jeu vidéo : " + "accès à toute la puissance du moteur." + ), + "screen_kind": "document", + "restricted": False, + }, +} + + +def get_onboarding_type_raw(slug): + """None si _meta['onboarding_type'] est absent — distingue un projet + JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par + l'admin via "+ Nouveau jeu") d'un projet explicitement "custom".""" + conn = db.connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'onboarding_type'").fetchone() + conn.close() + return row["value"] if row else None + + +def get_onboarding_type(slug): + return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE + + +def set_onboarding_type(slug, onboarding_type): + conn = db.connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)", + (onboarding_type,), + ) + conn.commit() + conn.close() + + +def is_restricted(onboarding_type_or_slug): + """Accepte directement une clé de ONBOARDING_TYPES, ou un slug de + projet (résout alors son onboarding_type d'abord) — pratique aussi + bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a + déjà la clé).""" + onboarding_type = ( + onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES + else get_onboarding_type(onboarding_type_or_slug) + ) + return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"] + + diff --git a/db/games/get_game_type.py b/db/games/get_game_type.py index ad1a1d1e..58162d1f 100644 --- a/db/games/get_game_type.py +++ b/db/games/get_game_type.py @@ -8,9 +8,10 @@ def get_game_type(slug): (é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é.""" + Stocké dans _meta (une table générique clé/valeur, déjà utilisée pour + d'autres réglages de projet) — 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() diff --git a/db/games/is_public_played.py b/db/games/is_public_played.py deleted file mode 100644 index 32064302..00000000 --- a/db/games/is_public_played.py +++ /dev/null @@ -1,14 +0,0 @@ -from ..connection import connect - - -def is_public_played(slug): - """Un jeu n'est jouable sur la route publique /jouer/ (voir - routes/public_play/) que si le créateur l'a explicitement basculé - "Publier en ligne" dans son tableau de bord — jamais par défaut, pour - ne pas exposer un jeu en cours de conception. Réutilise la table - générique _meta (voir game_meta.py, déjà utilisée pour 'name'), - aucune nouvelle colonne/table nécessaire.""" - conn = connect(slug) - row = conn.execute("SELECT value FROM _meta WHERE key = 'is_public_played'").fetchone() - conn.close() - return bool(row and row["value"] == "1") diff --git a/db/games/list_games.py b/db/games/list_games.py index 656bf76d..19b34ec6 100644 --- a/db/games/list_games.py +++ b/db/games/list_games.py @@ -3,18 +3,35 @@ import os from ..constants import PROJECTS_DIR from ..connection import connect from ..db_path import db_path +from .project_slug import build_slug def list_games(): - """Scanne le dossier projects/ : chaque sous-dossier avec un game.db est un jeu.""" + """Scanne projects/ : structure par utilisateur (voir + db/games/project_slug.py) — projects///game.db, + deux niveaux — plus le repli sur l'ancien rangement plat + projects//game.db pour tout slug pas encore migré (voir + scripts/migrate_flat_project_slugs.py).""" games = [] if not os.path.isdir(PROJECTS_DIR): return games - for slug in sorted(os.listdir(PROJECTS_DIR)): - path = db_path(slug) - if os.path.isfile(path): - conn = connect(slug) - row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() - conn.close() - games.append({"slug": slug, "name": row["value"] if row else slug}) + for entry in sorted(os.listdir(PROJECTS_DIR)): + entry_path = os.path.join(PROJECTS_DIR, entry) + if not os.path.isdir(entry_path): + continue + if os.path.isfile(os.path.join(entry_path, "game.db")): + # Repli : un ancien dossier plat porte directement game.db. + _append_game(games, entry) + continue + for project_part in sorted(os.listdir(entry_path)): + slug = build_slug(entry, project_part) + if os.path.isfile(db_path(slug)): + _append_game(games, slug) return games + + +def _append_game(games, slug): + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() + conn.close() + games.append({"slug": slug, "name": row["value"] if row else slug}) diff --git a/db/games/move_game.py b/db/games/move_game.py index 387ba126..a8ecd869 100644 --- a/db/games/move_game.py +++ b/db/games/move_game.py @@ -1,23 +1,43 @@ import os +from ..constants import PROJECTS_DIR from ..game_dir import game_dir +from .project_slug import build_slug, split_slug -def move_game(old_slug, new_slug): - """Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un +def move_game(old_slug, new_owner_folder): + """Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un utilisateur change son adresse email (voir routes/auth/profile.py, - project_slug = slugify(email) pour un compte "user") : le dossier - physique du jeu doit suivre. Rend le nouveau slug unique de la même - façon que create_game() si, par un hasard extrême, il correspond déjà - à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé), - à enregistrer comme nouveau project_slug.""" - if old_slug == new_slug: + owner_folder = slugify(email) pour un compte "user", voir + db/games/project_slug.py) : le dossier physique doit suivre. Renomme + le dossier propriétaire ENTIER en un coup (déplace tous les projets de + ce compte ensemble — prêt pour un futur multi-projet, même si un seul + aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire + unique de la même façon que create_game() si, par un hasard extrême, + il correspond déjà à un dossier existant. Renvoie le nouveau slug + composé FINAL (même projet, propriétaire renommé), à enregistrer comme + nouveau project_slug.""" + old_owner_folder, project_part = split_slug(old_slug) + if project_part is None: + # Slug pas encore migré vers la structure par utilisateur (voir + # scripts/migrate_flat_project_slugs.py) — repli sur l'ancien + # comportement plat (renomme le dossier du projet lui-même). + if old_slug == new_owner_folder: + return old_slug + base = new_owner_folder + n = 2 + while os.path.isdir(game_dir(base)): + base = f"{new_owner_folder}-{n}" + n += 1 + os.rename(game_dir(old_slug), game_dir(base)) + return base + + if old_owner_folder == new_owner_folder: return old_slug - base = new_slug + base = new_owner_folder n = 2 - while os.path.isdir(game_dir(base)): - base = f"{new_slug}-{n}" + while os.path.isdir(os.path.join(PROJECTS_DIR, base)): + base = f"{new_owner_folder}-{n}" n += 1 - new_slug = base - os.rename(game_dir(old_slug), game_dir(new_slug)) - return new_slug + os.rename(os.path.join(PROJECTS_DIR, old_owner_folder), os.path.join(PROJECTS_DIR, base)) + return build_slug(base, project_part) diff --git a/db/games/project_slug.py b/db/games/project_slug.py new file mode 100644 index 00000000..be9a8043 --- /dev/null +++ b/db/games/project_slug.py @@ -0,0 +1,32 @@ +"""Convention du slug composé "propriétaire_projet" (voir le plan +"Structure de dossiers propriétaire/projet") : un slug encode en réalité +deux segments — le dossier PROPRIÉTAIRE (slugifié depuis l'email du +compte qui a créé le projet) et le dossier PROJET à l'intérieur — pour +que projects/ soit une vraie arborescence par utilisateur +(projects///), sans jamais toucher aux ~40 fichiers +de routes qui déclarent dans leur URL : le slug reste, pour elles, +un seul segment plat et opaque. + +"_" comme séparateur est sûr et non ambigu : slugify() (voir +db/slugify.py) ne produit jamais de "_", seulement [a-z0-9-]+ — un slug +composé peut donc toujours être découpé sans collision sur la PREMIÈRE +occurrence de "_" (le dossier propriétaire, lui, n'en contient jamais). + +Seule une poignée de fonctions dans db/games/ (game_dir, create_game, +move_game, list_games) savent que le slug encode ce chemin composé — si +la convention change un jour, ce fichier est le seul à modifier.""" + + +def build_slug(owner_folder, project_part): + return f"{owner_folder}_{project_part}" + + +def split_slug(slug): + """(owner_folder, project_part) si `slug` est bien composé, sinon + (slug, None) — un slug "plat" (créé avant cette convention, pas encore + migré par scripts/migrate_flat_project_slugs.py) reste lisible tel + quel par le code appelant (repli sur l'ancien comportement).""" + owner_folder, sep, project_part = slug.partition("_") + if not sep: + return slug, None + return owner_folder, project_part diff --git a/db/games/set_public_played.py b/db/games/set_public_played.py deleted file mode 100644 index 2842e47f..00000000 --- a/db/games/set_public_played.py +++ /dev/null @@ -1,14 +0,0 @@ -from ..connection import connect - - -def set_public_played(slug, value): - """Bascule "Publier en ligne" (tableau de bord) — voir - is_public_played.py. `value` : bool.""" - conn = connect(slug) - conn.execute( - "INSERT INTO _meta (key, value) VALUES ('is_public_played', ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - ("1" if value else "0",), - ) - conn.commit() - conn.close() diff --git a/db/quests/constants.py b/db/quests/constants.py new file mode 100644 index 00000000..ca54a481 --- /dev/null +++ b/db/quests/constants.py @@ -0,0 +1,5 @@ +QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"] +QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"} +QUEST_RESULT_CHOICES = ["reussi", "echoue"] +QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"} +QUEST_SPEAKER_CHOICES = ["joueur", "pnj"] diff --git a/db/quests/create_quest.py b/db/quests/create_quest.py new file mode 100644 index 00000000..e58748a5 --- /dev/null +++ b/db/quests/create_quest.py @@ -0,0 +1,18 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest + + +def create_quest(slug, title="Nouvelle quête"): + """Titre par défaut plutôt qu'un refus — créée d'un clic depuis la + liste (voir l'onglet "🗺️ Quêtes", templates/scene_edit.html) ou depuis l'assistant "+ Action" + de l'éditeur de collision (voir collision-rules-editor.js), avant + même que l'auteur ait choisi un titre.""" + ensure_quests_schema(slug) + title = (title or "").strip() or "Nouvelle quête" + conn = connect(slug) + conn.execute("INSERT INTO _quests (title) VALUES (?)", (title,)) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return get_quest(slug, new_id) diff --git a/db/quests/delete_quest.py b/db/quests/delete_quest.py new file mode 100644 index 00000000..80e19b75 --- /dev/null +++ b/db/quests/delete_quest.py @@ -0,0 +1,10 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema + + +def delete_quest(slug, quest_id): + ensure_quests_schema(slug) + conn = connect(slug) + conn.execute("DELETE FROM _quests WHERE id = ?", (quest_id,)) + conn.commit() + conn.close() diff --git a/db/quests/ensure_quests_schema.py b/db/quests/ensure_quests_schema.py new file mode 100644 index 00000000..8013cd81 --- /dev/null +++ b/db/quests/ensure_quests_schema.py @@ -0,0 +1,29 @@ +from ..connection import connect + + +def ensure_quests_schema(slug): + """Migration légère (même principe que ensure_custom_events_schema.py) : + crée _quests si absente. Une quête vit pour TOUT le jeu (pas par écran), + référencée par id technique depuis l'action "quete" d'une règle de + collision (voir screens/rendering/collision_rules.py, quete_id). Les + dialogues (voir static/js/quests/quest-editor.js — 3 colonnes + nouvelle/en_cours/terminee) sont stockés en JSON plutôt qu'une table à + part : jamais interrogés/filtrés côté SQL, toujours lus/réécrits en + bloc pour UNE quête à la fois (même convention que _scene_objects.attributes).""" + conn = connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _quests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + objectif TEXT DEFAULT '', + recompense_score INTEGER DEFAULT 0, + status TEXT DEFAULT 'nouvelle', + resultat TEXT, + dialogues_json TEXT DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/db/quests/get_quest.py b/db/quests/get_quest.py new file mode 100644 index 00000000..634d6e70 --- /dev/null +++ b/db/quests/get_quest.py @@ -0,0 +1,11 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .list_quests import _row_to_quest + + +def get_quest(slug, quest_id): + ensure_quests_schema(slug) + conn = connect(slug) + row = conn.execute("SELECT * FROM _quests WHERE id = ?", (quest_id,)).fetchone() + conn.close() + return _row_to_quest(row) if row else None diff --git a/db/quests/list_quests.py b/db/quests/list_quests.py new file mode 100644 index 00000000..734a160a --- /dev/null +++ b/db/quests/list_quests.py @@ -0,0 +1,22 @@ +import json + +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema + + +def _row_to_quest(row): + quest = dict(row) + try: + quest["dialogues"] = json.loads(quest.pop("dialogues_json") or "{}") + except (TypeError, ValueError): + quest["dialogues"] = {} + quest.pop("dialogues_json", None) + return quest + + +def list_quests(slug): + ensure_quests_schema(slug) + conn = connect(slug) + rows = conn.execute("SELECT * FROM _quests ORDER BY created_at DESC, id DESC").fetchall() + conn.close() + return [_row_to_quest(r) for r in rows] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py new file mode 100644 index 00000000..dfaef61d --- /dev/null +++ b/db/quests/sanitize_quest_dialogues.py @@ -0,0 +1,30 @@ +from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES + +_MAX_LINES_PER_COLUMN = 200 + + +def sanitize_quest_dialogues(raw_dialogues): + """Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne + par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que + resolve_collision_rules.sanitize_collision_rules : rejette tout + élément invalide plutôt que de lever, ne garde jamais un texte vide.""" + result = {status: [] for status in QUEST_STATUS_CHOICES} + if not isinstance(raw_dialogues, dict): + return result + for status in QUEST_STATUS_CHOICES: + raw_lines = raw_dialogues.get(status) + if not isinstance(raw_lines, list): + continue + cleaned = [] + for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]: + if not isinstance(raw_line, dict): + continue + speaker = raw_line.get("speaker") + text = raw_line.get("text") + if speaker not in QUEST_SPEAKER_CHOICES: + continue + if not isinstance(text, str) or not text.strip(): + continue + cleaned.append({"speaker": speaker, "text": text.strip()}) + result[status] = cleaned + return result diff --git a/db/quests/set_quest_dialogues.py b/db/quests/set_quest_dialogues.py new file mode 100644 index 00000000..62a4ed65 --- /dev/null +++ b/db/quests/set_quest_dialogues.py @@ -0,0 +1,21 @@ +import json + +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest +from .sanitize_quest_dialogues import sanitize_quest_dialogues + + +def set_quest_dialogues(slug, quest_id, raw_dialogues): + """Toujours les 3 colonnes ENTIÈRES (nouvelle/en_cours/terminee) en un + bloc — même convention "replace-all" que set_scene_object_collision_rules.py, + jamais une réplique isolée.""" + ensure_quests_schema(slug) + if get_quest(slug, quest_id) is None: + return None + dialogues = sanitize_quest_dialogues(raw_dialogues) + conn = connect(slug) + conn.execute("UPDATE _quests SET dialogues_json = ? WHERE id = ?", (json.dumps(dialogues), quest_id)) + conn.commit() + conn.close() + return dialogues diff --git a/db/quests/update_quest.py b/db/quests/update_quest.py new file mode 100644 index 00000000..f8fec251 --- /dev/null +++ b/db/quests/update_quest.py @@ -0,0 +1,42 @@ +from ..connection import connect +from .constants import QUEST_STATUS_CHOICES, QUEST_RESULT_CHOICES +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest + + +def update_quest(slug, quest_id, fields): + """Met à jour uniquement les clés reconnues et valides de `fields` — + ignore tout le reste plutôt que de lever, même esprit que + sanitize_collision_rules (jamais confiance dans ce qu'envoie le + client). title vide => ignoré (garde l'ancien plutôt qu'un titre vide).""" + ensure_quests_schema(slug) + if get_quest(slug, quest_id) is None: + return None + updates = {} + if "title" in fields: + title = (fields["title"] or "").strip() + if title: + updates["title"] = title + if "objectif" in fields: + updates["objectif"] = (fields["objectif"] or "").strip() + if "recompense_score" in fields: + try: + updates["recompense_score"] = int(fields["recompense_score"]) + except (TypeError, ValueError): + pass + if "status" in fields and fields["status"] in QUEST_STATUS_CHOICES: + updates["status"] = fields["status"] + if "resultat" in fields: + resultat = fields["resultat"] + if resultat in QUEST_RESULT_CHOICES: + updates["resultat"] = resultat + elif resultat is None: + updates["resultat"] = None + if not updates: + return get_quest(slug, quest_id) + conn = connect(slug) + set_clause = ", ".join(f"{key} = ?" for key in updates) + conn.execute(f"UPDATE _quests SET {set_clause} WHERE id = ?", (*updates.values(), quest_id)) + conn.commit() + conn.close() + return get_quest(slug, quest_id) diff --git a/db/scoring/__init__.py b/db/scoring/__init__.py new file mode 100644 index 00000000..66c320c2 --- /dev/null +++ b/db/scoring/__init__.py @@ -0,0 +1,6 @@ +"""Score/progression (voir le plan "Feuille de route produit") : un +concept de premier ordre, distinct du système de variables globales +(db/global_vars/) — un score numérique et un statut de partie, TOUJOURS +par joueur (voir db.PLAYER_SHARED pour l'aperçu créateur), préalable aux +exports SCORM/xAPI (qui ont besoin d'un signal "score"/"terminé" propre, +pas d'une convention sur une variable choisie par le créateur).""" diff --git a/db/scoring/ensure_scoring_schema.py b/db/scoring/ensure_scoring_schema.py new file mode 100644 index 00000000..90a656ca --- /dev/null +++ b/db/scoring/ensure_scoring_schema.py @@ -0,0 +1,22 @@ +from ..connection import connect + + +def ensure_scoring_schema(slug): + """Migration légère (même principe que ensure_global_vars_schema.py) : + crée _scoring si absente. Une seule ligne par joueur (player_id, voir + db.PLAYER_SHARED pour l'aperçu créateur — même modèle que + db/global_vars/, mais pas de notion "per_player=0" ici : le score + d'UN joueur n'a de sens que pour CE joueur, jamais partagé).""" + conn = connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _scoring ( + player_id TEXT PRIMARY KEY, + score REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'non_commence', + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/db/scoring/get_score.py b/db/scoring/get_score.py new file mode 100644 index 00000000..dcda310b --- /dev/null +++ b/db/scoring/get_score.py @@ -0,0 +1,17 @@ +from ..connection import connect +from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .ensure_scoring_schema import ensure_scoring_schema + + +def get_score(slug, player_id=PLAYER_SHARED): + """Score/statut de CE joueur — (0, "non_commence") tant qu'il n'a + encore rien écrit (voir set_score_value.py/set_status.py, qui créent + la ligne à la première écriture, même patron que + update_global_variable_value.py).""" + ensure_scoring_schema(slug) + conn = connect(slug) + row = conn.execute("SELECT * FROM _scoring WHERE player_id = ?", (player_id,)).fetchone() + conn.close() + if not row: + return {"player_id": player_id, "score": 0, "status": "non_commence"} + return dict(row) diff --git a/db/scoring/set_score_value.py b/db/scoring/set_score_value.py new file mode 100644 index 00000000..323f50ed --- /dev/null +++ b/db/scoring/set_score_value.py @@ -0,0 +1,23 @@ +from ..connection import connect +from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .ensure_scoring_schema import ensure_scoring_schema + + +def set_score_value(slug, new_value, player_id=PLAYER_SHARED): + """Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant, voir + screens/data_actions/apply_score_action.py) — même patron que + update_global_variable_value.py : la ligne de ce joueur est créée à sa + toute première écriture (INSERT ... ON CONFLICT évite un aller-retour + SELECT préalable, un joueur ne peut écrire que la SIENNE).""" + ensure_scoring_schema(slug) + conn = connect(slug) + conn.execute( + """ + INSERT INTO _scoring (player_id, score) VALUES (?, ?) + ON CONFLICT(player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP + """, + (player_id, new_value), + ) + conn.commit() + conn.close() + return True diff --git a/db/scoring/set_status.py b/db/scoring/set_status.py new file mode 100644 index 00000000..a0cffa78 --- /dev/null +++ b/db/scoring/set_status.py @@ -0,0 +1,25 @@ +from ..connection import connect +from ..constants import SCORE_STATUS_LABELS +from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED +from .ensure_scoring_schema import ensure_scoring_schema + + +def set_status(slug, status, player_id=PLAYER_SHARED): + """Voir set_score_value.py pour le patron (upsert, ligne créée à la + première écriture). `status` doit être une clé de + db.SCORE_STATUS_LABELS — un statut inconnu (ex. faute de frappe dans + une valeur de nœud de flux) est refusé plutôt qu'écrit tel quel.""" + if status not in SCORE_STATUS_LABELS: + return False + ensure_scoring_schema(slug) + conn = connect(slug) + conn.execute( + """ + INSERT INTO _scoring (player_id, status) VALUES (?, ?) + ON CONFLICT(player_id) DO UPDATE SET status = excluded.status, updated_at = CURRENT_TIMESTAMP + """, + (player_id, status), + ) + conn.commit() + conn.close() + return True diff --git a/publish/__init__.py b/publish/__init__.py index 6be89863..3e889048 100644 --- a/publish/__init__.py +++ b/publish/__init__.py @@ -1,3 +1,3 @@ -"""publish — empaquette un jeu en exécutable Windows autonome (zip avec -serveur interne, voir build_package.py) : le bouton "Publier" de la barre +"""publish — empaquette un jeu en export Web/SCORM (voir +build_scorm_package.py) : le bouton "Exporter (Web/SCORM)" de la barre de navigation d'un jeu (templates/base.html).""" diff --git a/publish/build_package.py b/publish/build_package.py deleted file mode 100644 index ca385235..00000000 --- a/publish/build_package.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Assemble le zip exporté d'un jeu (bouton "Publier", voir -routes/publish/publish_game.py) : un dossier autonome contenant Python -portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie -figée du jeu (screens/db/filters/core minimal + templates/play.html + -le sous-ensemble utile de static/), sa base SQLite et ses fichiers -envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat). - -Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un -dossier de travail temporaire, supprimé après envoi de la réponse) ni la -vraie base de données du jeu (copiée, jamais déplacée).""" -import os -import shutil -import tempfile -import zipfile - -import db -import screens - -from . import vendor_runtime - -_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__)) -_BASE_DIR = os.path.dirname(_PUBLISH_DIR) - -# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir -# templates/play.html) — jamais les assets propres à l'éditeur seul -# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/). -_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play", "characters"] - -_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER" - - -def _copy_characters(static_dir, used_forge_characters): - """`static/characters/` contient 2 rangements bien distincts (voir le - plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat - Kenney (`/*.png`, CC0, ~6 Mo, toujours copié comme avant) et - `animals///` (CraftPix, ~1,5-2 Go pour les 210 variantes) - — celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN) - correspondant aux personnages `animal--` réellement - utilisés par CE jeu (used_forge_characters), pour ne pas gonfler - CHAQUE .zip exporté du poids de toute la bibliothèque. manifest.json - est toujours copié : screens/labels/animal_sprite_library.py le lit à - l'import, sans lui `screens` (copié intégralement juste après) ne - s'importerait plus dans le paquet exporté.""" - src_root = os.path.join(_BASE_DIR, "static", "characters") - dst_root = os.path.join(static_dir, "characters") - for name in os.listdir(src_root): - if name == "animals": - continue - src = os.path.join(src_root, name) - dst = os.path.join(dst_root, name) - if os.path.isdir(src): - shutil.copytree(src, dst) - else: - os.makedirs(dst_root, exist_ok=True) - shutil.copy2(src, dst) - - animals_src_root = os.path.join(src_root, "animals") - if not os.path.isdir(animals_src_root): - return - animals_dst_root = os.path.join(dst_root, "animals") - os.makedirs(animals_dst_root, exist_ok=True) - shutil.copy2(os.path.join(animals_src_root, "manifest.json"), os.path.join(animals_dst_root, "manifest.json")) - used_variants = set() - for slug in used_forge_characters: - # slug = "animal--" (voir screens/labels/animal_sprite_library.py) ; - # ignore silencieusement tout ce qui ne colle pas à ce format — - # un personnage Kenney (déjà copié ci-dessus) n'a pas ce préfixe. - if not slug.startswith("animal-"): - continue - animal, _, nn = slug[len("animal-"):].rpartition("-") - if animal: - used_variants.add((animal, nn)) - for animal, nn in used_variants: - src = os.path.join(animals_src_root, animal, nn) - if os.path.isdir(src): - shutil.copytree(src, os.path.join(animals_dst_root, animal, nn)) - - -def _copy_engine_sources(staging_dir, slug): - """Copie la partie du moteur nécessaire au mode jouable SEUL (jamais - l'éditeur ni l'authentification — voir le constat d'exploration dans - le plan : screens/ et db/ sont déjà totalement découplés de auth// - routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS - fichiers de core/ dont ils dépendent réellement).""" - for pkg in ("screens", "db", "filters"): - shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg), - ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) - - core_dir = os.path.join(staging_dir, "core") - os.makedirs(core_dir, exist_ok=True) - for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"): - shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname)) - - templates_dir = os.path.join(staging_dir, "templates") - os.makedirs(templates_dir, exist_ok=True) - shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html")) - - static_dir = os.path.join(staging_dir, "static") - used_forge_characters = screens.list_used_forge_characters(slug) - for item in _STATIC_ITEMS: - if item == "characters": - _copy_characters(static_dir, used_forge_characters) - continue - src = os.path.join(_BASE_DIR, "static", item) - dst = os.path.join(static_dir, item) - os.makedirs(os.path.dirname(dst), exist_ok=True) - if os.path.isdir(src): - shutil.copytree(src, dst) - else: - shutil.copy2(src, dst) - - -def _copy_game_data(staging_dir, slug): - """Copie le dossier RÉEL du jeu (game.db + uploads/, voir - db.game_dir) tel quel — jamais déplacé, jamais modifié.""" - src = db.game_dir(slug) - dst = os.path.join(staging_dir, "projects", slug) - shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js")) - - -def _write_player_app(staging_dir, slug): - template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py") - with open(template_path, encoding="utf-8") as f: - content = f.read() - content = content.replace(_SLUG_PLACEHOLDER, slug) - with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f: - f.write(content) - - -def _write_run_bat(staging_dir): - src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat") - shutil.copy2(src, os.path.join(staging_dir, "run.bat")) - - -def _zip_dir(staging_dir, zip_path): - with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for root, _dirs, files in os.walk(staging_dir): - for name in files: - full = os.path.join(root, name) - rel = os.path.relpath(full, staging_dir) - zf.write(full, rel) - - -def build_game_zip(slug): - """Construit le zip et renvoie son chemin (dans un dossier temporaire - — à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir - routes/publish/publish_game.py).""" - game = db.game_meta(slug) - python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready() - - staging_dir = tempfile.mkdtemp(prefix="forge_publish_") - try: - shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed")) - shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs")) - _copy_engine_sources(staging_dir, slug) - _copy_game_data(staging_dir, slug) - _write_player_app(staging_dir, slug) - _write_run_bat(staging_dir) - - zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip") - os.close(zip_fd) - _zip_dir(staging_dir, zip_path) - finally: - shutil.rmtree(staging_dir, ignore_errors=True) - return zip_path, game["name"] diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py new file mode 100644 index 00000000..0b0b99d5 --- /dev/null +++ b/publish/build_scorm_package.py @@ -0,0 +1,271 @@ +"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir +routes/publish/export_scorm.py) — paquet STATIQUE : index.html porte tout +le runtime jouable directement en mémoire (window.FORGE_OFFLINE, voir +static/js/play/offline/) puisqu'un SCORM tourne SEUL dans le LMS du +client, sans serveur Forge Engine disponible.""" +import base64 +import json +import os +import shutil +import tempfile +import zipfile + +from flask import render_template + +import db +import screens + +from .scorm_manifest import build_manifest_xml + +_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__)) +_BASE_DIR = os.path.dirname(_PUBLISH_DIR) + +# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir +# templates/play.html) — jamais les assets propres à l'éditeur seul +# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/). +_STATIC_ITEMS = [ + "style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", + "js/play", "characters", "backgrounds", +] + + +def _copy_characters(static_dir, used_forge_characters): + """`static/characters/` contient 2 rangements bien distincts (voir le + plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat + Kenney (`/*.png`, CC0, ~6 Mo, toujours copié comme avant) et + `animals///` (CraftPix, ~1,5-2 Go pour les 210 variantes) + — celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN) + correspondant aux personnages `animal--` réellement + utilisés par CE jeu (used_forge_characters), pour ne pas gonfler + CHAQUE .zip exporté du poids de toute la bibliothèque. manifest.json + est toujours copié : screens/labels/animal_sprite_library.py le lit à + l'import, sans lui `screens` (copié intégralement juste après) ne + s'importerait plus dans le paquet exporté.""" + src_root = os.path.join(_BASE_DIR, "static", "characters") + dst_root = os.path.join(static_dir, "characters") + for name in os.listdir(src_root): + if name == "animals": + continue + src = os.path.join(src_root, name) + dst = os.path.join(dst_root, name) + if os.path.isdir(src): + shutil.copytree(src, dst) + else: + os.makedirs(dst_root, exist_ok=True) + shutil.copy2(src, dst) + + animals_src_root = os.path.join(src_root, "animals") + if not os.path.isdir(animals_src_root): + return + animals_dst_root = os.path.join(dst_root, "animals") + os.makedirs(animals_dst_root, exist_ok=True) + shutil.copy2(os.path.join(animals_src_root, "manifest.json"), os.path.join(animals_dst_root, "manifest.json")) + used_variants = set() + for character_slug in used_forge_characters: + # character_slug = "animal--" (voir + # screens/labels/animal_sprite_library.py) ; ignore silencieusement + # tout ce qui ne colle pas à ce format — un personnage Kenney + # (déjà copié ci-dessus) n'a pas ce préfixe. + if not character_slug.startswith("animal-"): + continue + animal, _, nn = character_slug[len("animal-"):].rpartition("-") + if animal: + used_variants.add((animal, nn)) + for animal, nn in used_variants: + src = os.path.join(animals_src_root, animal, nn) + if os.path.isdir(src): + shutil.copytree(src, os.path.join(animals_dst_root, animal, nn)) + + +def _copy_static_assets(staging_dir, slug): + static_dir = os.path.join(staging_dir, "static") + used_forge_characters = screens.list_used_forge_characters(slug) + for item in _STATIC_ITEMS: + if item == "characters": + _copy_characters(static_dir, used_forge_characters) + continue + src = os.path.join(_BASE_DIR, "static", item) + if not os.path.exists(src): + # "backgrounds" (voir screens/labels/background_library.py) est + # optionnel — absent tant que + # scripts/generate_background_manifest.py n'a jamais été + # lancé sur ce poste, jamais une erreur. + continue + dst = os.path.join(static_dir, item) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.isdir(src): + # "js/play" inclut déjà static/js/play/offline/ (dont + # scorm-api.js, jamais chargé en ligne mais propre à ce + # paquet) — seuls ses __tests__/ (node:test, inutiles dans + # un LMS) sont exclus. + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__tests__") if item == "js/play" else None) + else: + shutil.copy2(src, dst) + + +def _copy_uploads(staging_dir, slug): + """Fichiers envoyés par le créateur (widget Image/Vidéo, "Envoyer un + fichier" — voir routes/uploads/upload_file.py) : servis EN LIGNE par + une route Flask (/game//uploads/, jamais static/), donc + jamais copiés par _copy_static_assets ci-dessus. Absents s'il n'y en a + aucun (os.path.isdir garde-fou).""" + src = os.path.join(db.game_dir(slug), "uploads") + if os.path.isdir(src): + shutil.copytree(src, os.path.join(staging_dir, "uploads")) + + +def _relativize_absolute_urls(html, slug): + """screens/rendering (render_icone.py) et screens/labels (sprite_ + library.py/animal_sprite_library.py) construisent certaines URLs en + DUR avec un préfixe absolu "/static/..." ou "/game//uploads/..." + (corrects EN LIGNE, où ils partent de la racine du serveur Flask) — + déjà bakées dans le HTML/JSON rendu à ce stade, impossible à + distinguer d'un bête texte sans réécrire chaque fonction de rendu une + à une. Un remplacement global est plus sûr : ces deux préfixes précis + (le second inclut le slug du jeu) n'ont aucune raison d'apparaître + ailleurs que dans une URL réellement générée par le moteur. Sans ce + correctif, un index.html ouvert en local (file://) échouait à charger + icônes/sprites/fichiers envoyés (CORS bloque une requête absolue vers + file:///static/... — "la requête CORS n'utilise pas HTTP"). + + Cas particulier des icônes (render_icone.py) : leur URL est posée dans + un style INLINE (--icon-url), mais CONSUMÉE par `mask-image: + var(--icon-url)` dans static/style.css (voir .icon-svg) — un chemin + RELATIF n'y suffit PAS (voir _build_icon_data_uris plus bas, qui + remplace ces chemins par des data: URI juste après cet appel) : Firefox + traite chaque ressource file:// comme une origine opaque distincte, et + mask-image y déclenche un fetch en mode CORS quel que soit le chemin. + "icons/x.svg" reste néanmoins la forme intermédiaire correcte que + _build_icon_data_uris recherche pour remplacer (elle-même résolue par + rapport à static/style.css, jamais index.html — piège CSS connu : + "static/icons/x.svg" y redeviendrait "static/static/icons/x.svg").""" + return ( + html.replace(f"/game/{slug}/uploads/", "uploads/") + .replace("/static/icons/", "icons/") + .replace("/static/", "static/") + ) + + +def _collect_icon_slugs(payload): + """Tout _icon_slug (widget "icone") réellement posé dans ce jeu — + élément normal ET élément niché dans un élément de jeu réutilisable + (element_type_templates, voir full_game_payload.py) — pour ne pas + embarquer ("data:" URI, voir _build_icon_data_uris) le catalogue + ENTIER (~1 Mo pour ~260 icônes) alors qu'un jeu n'en utilise + typiquement que quelques-unes.""" + slugs = set() + + def scan(elements): + for el in elements or []: + if el.get("widget") == "icone": + slugs.add((el.get("attributes") or {}).get("_icon_slug") or "star") + + for s in payload.get("screens", []): + scan(s.get("elements")) + for tree in (payload.get("element_type_templates") or {}).values(): + scan(tree.get("top")) + for kids in (tree.get("children_map") or {}).values(): + scan(kids) + return slugs + + +def _build_icon_data_uris(slugs): + """Encode chaque icône RÉELLEMENT utilisée (voir _collect_icon_slugs) + en data: URI. Nécessaire en plus de _relativize_absolute_urls : Firefox + traite CHAQUE ressource file:// comme une origine opaque distincte, et + mask-image y déclenche un fetch en mode CORS même pour un chemin + relatif par ailleurs tout à fait valide ("Blocage d'une requête + multiorigine... la requête CORS n'utilise pas HTTP", signalé par un + utilisateur MALGRÉ le chemin déjà corrigé) — aucun chemin de fichier, + aussi correct soit-il, ne peut donc fonctionner sous file://. Une + data: URI n'est PAS une requête réseau séparée : elle contourne + entièrement ce problème (comme pour toute autre resource CSS + référencée par chemin sous file://).""" + uris = {} + icons_dir = os.path.join(_BASE_DIR, "static", "icons") + for icon_slug in slugs: + path = os.path.join(icons_dir, f"{icon_slug}.svg") + if not os.path.isfile(path): + continue + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("ascii") + uris[icon_slug] = f"data:image/svg+xml;base64,{b64}" + return uris + + +def _inline_icon_urls(html, icon_data_uris): + """Remplace chaque "icons/.svg" (forme relative déjà posée par + _relativize_absolute_urls/render_icone.py) par sa data: URI — s'applique + aussi bien au rendu initial (déjà bake dans le payload JSON embarqué + dans cette même page) qu'à toute régénération faite plus tard côté JS + (voir forgeRenderIcone, qui lit directement gameData.icon_data_uris + plutôt que de reconstruire un chemin de fichier).""" + for icon_slug, uri in icon_data_uris.items(): + html = html.replace(f"icons/{icon_slug}.svg", uri) + return html + + +def _list_package_files(staging_dir): + """Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir + (hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS + pour qu'un validateur strict (ADL SCORM Cloud) accepte le paquet.""" + rels = [] + for root, _dirs, files in os.walk(staging_dir): + for name in files: + full = os.path.join(root, name) + rel = os.path.relpath(full, staging_dir) + if rel != "index.html": + rels.append(rel) + return rels + + +def _zip_dir(staging_dir, zip_path): + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(staging_dir): + for name in files: + full = os.path.join(root, name) + rel = os.path.relpath(full, staging_dir) + zf.write(full, rel) + + +def build_scorm_zip(slug): + """Construit le zip Web/SCORM et renvoie (chemin, nom du jeu) — à + l'appelant de supprimer le zip une fois la réponse HTTP envoyée (voir + routes/publish/export_scorm.py). Doit être appelé DANS une requête + Flask en cours (render_template a besoin du contexte applicatif).""" + game = db.game_meta(slug) + # PLAYER_SHARED (aucun player_id) : un paquet exporté est joué par une + # seule personne à la fois sur son poste, comme /game//play — + # jamais d'état "par joueur" à distinguer hors ligne. + payload = screens.full_game_payload(slug) + # gameData.icon_data_uris (voir forgeRenderIcone, + # static/js/play/offline/render-special-widgets.js) : ajouté au + # payload AVANT le rendu pour qu'il fasse partie du même payload_json + # embarqué, sans passe supplémentaire. + payload["icon_data_uris"] = _build_icon_data_uris(_collect_icon_slugs(payload)) + + staging_dir = tempfile.mkdtemp(prefix="forge_scorm_") + try: + _copy_static_assets(staging_dir, slug) + _copy_uploads(staging_dir, slug) + + html = render_template( + "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + offline_mode=True, asset_url=lambda filename: "static/" + filename, + ) + html = _relativize_absolute_urls(html, slug) + html = _inline_icon_urls(html, payload["icon_data_uris"]) + with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f: + f.write(html) + + package_files = _list_package_files(staging_dir) + manifest_xml = build_manifest_xml(slug, game["name"], package_files) + with open(os.path.join(staging_dir, "imsmanifest.xml"), "w", encoding="utf-8") as f: + f.write(manifest_xml) + + zip_fd, zip_path = tempfile.mkstemp(prefix="forge_scorm_", suffix=".zip") + os.close(zip_fd) + _zip_dir(staging_dir, zip_path) + finally: + shutil.rmtree(staging_dir, ignore_errors=True) + return zip_path, game["name"] diff --git a/publish/player_app_template.py b/publish/player_app_template.py deleted file mode 100644 index c4e976dc..00000000 --- a/publish/player_app_template.py +++ /dev/null @@ -1,107 +0,0 @@ -"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL -jeu (voir publish/build_package.py) : aucune installation externe requise, -lancé par double-clic sur run.bat (Python portable + Flask embarqués à -côté de ce fichier). Reprend telles quelles les routes utilisées par le -mode jouable (voir templates/play.html) — jamais l'éditeur ni -l'authentification, absents de cet export. - -SLUG est remplacé littéralement par build_package.py au moment de la -publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté -tel quel depuis le dépôt source.""" -import json -import os -import socket -import sys -import threading -import webbrowser - -_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - -# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py) -# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/) -# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus -# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/ -# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à -# côté de ce fichier. -sys.path.insert(0, _BASE_DIR) - -# Doit être posé AVANT tout import de db/ : db/constants.py lit -# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le -# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du -# poste où le jeu a été publié. -os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects") - -from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402 - -from core.flask_app import app # noqa: E402 -from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname) -import db # noqa: E402 -import screens # noqa: E402 - -# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) — -# play.html appelle quand même csrf_token() en Jinja (voir base.html/ -# play.html) : un no-op le laisse fonctionner tel quel, sans le forker. -app.jinja_env.globals["csrf_token"] = lambda: "" - -SLUG = "_SLUG_PLACEHOLDER" - - -@app.route("/") -def index(): - return redirect(url_for("game_play", slug=SLUG)) - - -@app.route("/game//play") -def game_play(slug): - game = db.game_meta(SLUG) - payload = screens.full_game_payload(SLUG) - return render_template( - "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), - ) - - -@app.route("/game//runtime-payload") -def runtime_payload(slug): - return jsonify(screens.full_game_payload(SLUG)) - - -@app.route("/game//flow/nodes//run-data", methods=["POST"]) -def flow_node_run_data(slug, node_id): - node = screens.get_flow_node(SLUG, node_id) - if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": - return jsonify({"ok": False}), 400 - if node.get("target_row_id") == screens.CLICKED_ROW_ID: - body = request.get_json(silent=True) or {} - node = {**node, "target_row_id": body.get("clicked_row_id")} - ok = screens.apply_data_action(SLUG, node) - return jsonify({"ok": ok}) - - -@app.route("/game//flow/nodes//run-variable", methods=["POST"]) -def flow_node_run_variable(slug, node_id): - node = screens.get_flow_node(SLUG, node_id) - if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable": - return jsonify({"ok": False}), 400 - ok = screens.apply_variable_action(SLUG, node) - return jsonify({"ok": ok}) - - -@app.route("/game//uploads/") -def uploaded_file(slug, filename): - return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename) - - -def _open_browser(port): - webbrowser.open(f"http://127.0.0.1:{port}/") - - -if __name__ == "__main__": - # Port libre choisi au hasard par l'OS (jamais le même serveur à - # relancer sur un port déjà pris par une partie précédente encore - # ouverte) — le navigateur est ouvert automatiquement, l'utilisateur - # n'a jamais besoin de connaître ni taper le port. - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - threading.Timer(1.0, _open_browser, args=(port,)).start() - app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False) diff --git a/publish/run_bat_template.bat b/publish/run_bat_template.bat deleted file mode 100644 index 660f8d43..00000000 --- a/publish/run_bat_template.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -cd /d "%~dp0" -python-embed\python.exe player_app.py -pause diff --git a/publish/scorm_manifest.py b/publish/scorm_manifest.py new file mode 100644 index 00000000..6cf42039 --- /dev/null +++ b/publish/scorm_manifest.py @@ -0,0 +1,48 @@ +"""Génère imsmanifest.xml (SCORM 1.2) pour le paquet Web/SCORM (voir +build_scorm_package.py). SCORM 1.2 plutôt que 2004 : support le plus +large chez les clients visés (Moodle, la plupart des LMS d'entreprise +acceptent les deux, l'inverse est faux).""" +import os +from xml.sax.saxutils import escape + +_MANIFEST_TEMPLATE = """ + + + ADL SCORM + 1.2 + + + + {title} + + {title} + + + + + + +{file_entries} + + + +""" + + +def build_manifest_xml(slug, game_name, package_files): + """`package_files` : chemins relatifs (depuis la racine du paquet, tel + qu'écrits dans le zip) de TOUS les fichiers autres que index.html — + un LMS strict (SCORM Cloud) rejette un manifeste qui omet un fichier + réellement présent dans le paquet.""" + file_entries = "\n".join( + ' '.format(escape(rel.replace(os.sep, "/"))) + for rel in package_files + ) + return _MANIFEST_TEMPLATE.format( + identifier=escape(slug), title=escape(game_name), file_entries=file_entries, + ) diff --git a/publish/vendor_runtime.py b/publish/vendor_runtime.py deleted file mode 100644 index 92824afa..00000000 --- a/publish/vendor_runtime.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter -un jeu en exécutable autonome : Python portable (embeddable, python.org) -et les paquets pip nécessaires (Flask + ses dépendances), mis en cache -localement sous data/publish_vendor/ (non commité, voir .gitignore — -même dossier que data/secret_key/data/users.db, déjà runtime-only). - -La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces -~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les -publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui -reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne -concerne que la machine qui PUBLIE. - -Fonctions séparées et mockables (download_python_embeddable/ -vendor_flask) pour que les tests ne déclenchent jamais un vrai -téléchargement.""" -import os -import subprocess -import sys -import urllib.request -import zipfile - -_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor") - -PYTHON_VERSION = "3.11.9" -PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" -PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64") -PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs") - -# Mêmes versions que requirements.txt — seul Flask est listé explicitement, -# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker) -# suivent automatiquement via pip. -FLASK_SPEC = "Flask==3.0.3" - - -def download_python_embeddable(): - """Télécharge et extrait le ZIP Python embeddable officiel si absent - du cache — no-op si déjà présent (idempotent, sûr à rappeler à - chaque publication).""" - marker = os.path.join(PYTHON_EMBED_DIR, "python.exe") - if os.path.isfile(marker): - return PYTHON_EMBED_DIR - os.makedirs(PYTHON_EMBED_DIR, exist_ok=True) - zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip") - urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path) - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(PYTHON_EMBED_DIR) - os.remove(zip_path) - _enable_pylibs_path(PYTHON_EMBED_DIR) - return PYTHON_EMBED_DIR - - -def _enable_pylibs_path(python_embed_dir): - """Le ZIP embeddable désactive site-packages par défaut - (python311._pth ne contient que "python311.zip" et "."). On ajoute une - ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver - tout le mécanisme "site" — les paquets vendorés (Flask...) sont de - simples dossiers purement Python, aucun besoin d'aller plus loin.""" - pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")] - if not pth_files: - return - pth_path = os.path.join(python_embed_dir, pth_files[0]) - with open(pth_path, encoding="utf-8") as f: - content = f.read() - if "pylibs" not in content: - with open(pth_path, "a", encoding="utf-8") as f: - f.write("\npylibs\n") - - -def vendor_flask(): - """pip install --target=... : installe Flask (paquets purs Python, - indépendants de la version exacte de l'interpréteur embeddable) dans - le cache, réutilisé par tous les exports suivants — no-op si déjà - fait.""" - if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR): - return PYLIBS_DIR - os.makedirs(PYLIBS_DIR, exist_ok=True) - subprocess.run( - [sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC], - check=True, - ) - return PYLIBS_DIR - - -def ensure_vendor_ready(): - """Point d'entrée unique appelé par build_package.py — télécharge/ - installe ce qui manque, puis renvoie (dossier_python_embed, - dossier_pylibs).""" - python_dir = download_python_embeddable() - pylibs_dir = vendor_flask() - return python_dir, pylibs_dir diff --git a/routes/__init__.py b/routes/__init__.py index 54de4fd4..0a52327b 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests diff --git a/routes/auth/login_2fa.py b/routes/auth/login_2fa.py index c5006a9b..f5373f6a 100644 --- a/routes/auth/login_2fa.py +++ b/routes/auth/login_2fa.py @@ -52,5 +52,10 @@ def login_2fa(): return redirect(next_url) if user["role"] == "admin": return redirect(url_for("index")) + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : + # un compte "user" peut ne pas encore avoir de projet du tout + # (inscription interrompue avant l'onboarding). + if not user.get("project_slug"): + return redirect(url_for("onboarding_new")) return redirect(url_for("game_dashboard", slug=user["project_slug"])) return render_template("auth/login_2fa.html", error=error) diff --git a/routes/auth/profile.py b/routes/auth/profile.py index 73726aa2..3bced677 100644 --- a/routes/auth/profile.py +++ b/routes/auth/profile.py @@ -1,5 +1,4 @@ import os -import shutil from flask import g, redirect, render_template, request, session, url_for @@ -106,9 +105,12 @@ def profile_delete(): # plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses # projets restent intacts. if user["role"] != "admin" and user.get("project_slug"): - game_dir = db.game_dir(user["project_slug"]) - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + if os.path.isdir(db.game_dir(user["project_slug"])): + # db.delete_game (voir db/games/project_slug.py — structure de + # dossiers par utilisateur) nettoie aussi le dossier + # propriétaire s'il devient vide, contrairement à un simple + # shutil.rmtree du dossier projet seul. + db.delete_game(user["project_slug"]) auth.delete_user(user["id"]) session.clear() diff --git a/routes/auth/register_2fa.py b/routes/auth/register_2fa.py index 9c2d32ef..ddb05db4 100644 --- a/routes/auth/register_2fa.py +++ b/routes/auth/register_2fa.py @@ -1,7 +1,6 @@ from flask import redirect, render_template, request, session, url_for import auth -import db from core.flask_app import app @@ -28,16 +27,6 @@ def register_2fa(): else: auth.reset_failed_attempts(user_id) auth.confirm_totp(user_id) - # Un compte "user" (pas "admin") reçoit ICI son unique projet, - # nommé d'après son adresse email (voir create_user.py — le - # rôle est déjà tranché à la création : premier compte jamais - # créé = admin, tous les suivants = user) — un admin, lui, - # reste libre de créer ses jeux comme avant ("+ Nouveau jeu"), - # aucun projet ne lui est imposé d'office. - if user["role"] != "admin": - slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"]) - auth.set_project_slug(user_id, slug) - user = auth.get_user_by_id(user_id) session.pop("pending_register_user_id", None) session["user_id"] = user_id # Générés ici, une seule fois, à l'instant même où la 2FA @@ -50,7 +39,14 @@ def register_2fa(): session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id) if user["role"] == "admin": return redirect(url_for("index")) - return redirect(url_for("game_dashboard", slug=user["project_slug"])) + # Un compte "user" (pas "admin") n'a plus de projet créé + # d'office ici — voir routes/onboarding/onboarding_new.py : il + # choisit d'abord un type de jeu et nomme son projet, PUIS + # auth.set_project_slug() y est appelé (core/auth_guard.py + # renvoie ici tout compte sans project_slug). Un admin, lui, + # reste libre de créer ses jeux comme avant ("+ Nouveau jeu"), + # aucun projet ne lui est imposé d'office. + return redirect(url_for("onboarding_new")) qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"]) return render_template( diff --git a/routes/flow/__init__.py b/routes/flow/__init__.py index 9864306b..4d46c046 100644 --- a/routes/flow/__init__.py +++ b/routes/flow/__init__.py @@ -6,6 +6,8 @@ from . import ( flow_edge_delete, flow_node_run_data, flow_node_run_variable, + flow_node_run_score, + flow_node_run_status, flow_node_run_add_row, runtime_payload, ) diff --git a/routes/flow/flow_node_run_score.py b/routes/flow/flow_node_run_score.py new file mode 100644 index 00000000..8a0b80a5 --- /dev/null +++ b/routes/flow/flow_node_run_score.py @@ -0,0 +1,18 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//run-score", methods=["POST"]) +def flow_node_run_score(slug, node_id): + """Exécuté côté serveur au moment du clic, en mode jouable, pour un + nœud Action de type "Modifier le score" — même principe que + flow_node_run_variable.py, pour le score plutôt qu'une variable + globale (voir db/scoring/, screens/data_actions/apply_score_action.py).""" + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_score": + return jsonify({"ok": False}), 400 + ok = screens.apply_score_action(slug, node) + return jsonify({"ok": ok}) diff --git a/routes/flow/flow_node_run_status.py b/routes/flow/flow_node_run_status.py new file mode 100644 index 00000000..89af2bc1 --- /dev/null +++ b/routes/flow/flow_node_run_status.py @@ -0,0 +1,17 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//run-status", methods=["POST"]) +def flow_node_run_status(slug, node_id): + """Exécuté côté serveur au moment du clic, en mode jouable, pour un + nœud Action de type "Définir le statut de la partie" — voir + db/scoring/, screens/data_actions/apply_status_action.py.""" + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "definir_statut_partie": + return jsonify({"ok": False}), 400 + ok = screens.apply_status_action(slug, node) + return jsonify({"ok": ok}) diff --git a/routes/games/__init__.py b/routes/games/__init__.py index bfc0d253..c7e6c026 100644 --- a/routes/games/__init__.py +++ b/routes/games/__init__.py @@ -1 +1 @@ -from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played +from . import index, games_new, game_dashboard, game_rename, game_delete, game_scoring_view diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index bbb71a0e..a1c10fed 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -13,8 +13,42 @@ def game_dashboard(slug): accès direct à chaque élément — la barre de navigation du jeu (voir base.html) permet déjà de sauter directement vers chaque éditeur, cette page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes - liens.""" + liens. + + Onboarding guidé (voir db/games/game_type_catalog.py) : un compte + "restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE — + juste ses écrans, en cartes avec un aperçu de leur contenu, plus un + bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le + projet : "jeu_2d" pour un RPG, "document" pour les autres — voir + routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/ + variables restent réservés à "Créer mon jeu de A à Z", la seule + formule pensée pour la puissance complète du moteur.""" game = db.game_meta(slug) + if db.is_restricted(slug): + return _render_simple_dashboard(slug, game) + return _render_full_dashboard(slug, game) + + +def _render_simple_dashboard(slug, game): + screen_list = screens.list_screens(slug) + previews = {} + for s in screen_list: + if s["kind"] == "jeu_2d": + objects = screens.list_scene_objects(slug, s["id"]) + for o in objects: + o["rendered_html"] = screens.render_scene_object(o) + previews[s["id"]] = {"kind": "jeu_2d", "objects": objects} + else: + previews[s["id"]] = { + "kind": "document", + "elements": screens.list_elements(slug, s["id"]), + } + return render_template( + "game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews, + ) + + +def _render_full_dashboard(slug, game): definitions = db.list_definitions(slug) # Détail complet (avec ses champs) de chaque objet, pour le panneau # d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon diff --git a/routes/games/game_scoring_view.py b/routes/games/game_scoring_view.py new file mode 100644 index 00000000..c53ae8c8 --- /dev/null +++ b/routes/games/game_scoring_view.py @@ -0,0 +1,16 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//scoring/") +def game_scoring_view(slug, player_id): + """Lecture du score/statut d'UN joueur (voir db/scoring/) — route + interne (protégée comme toute route /game/, voir + core/auth_guard.py), pas destinée au joueur lui-même : préparée pour + être consommée par les futurs exports SCORM/xAPI (voir le plan + "Feuille de route produit"), qui ont besoin de lire ce signal une fois + la partie terminée.""" + return jsonify(db.get_score(slug, player_id)) diff --git a/routes/games/game_toggle_public_played.py b/routes/games/game_toggle_public_played.py deleted file mode 100644 index a50e7d5b..00000000 --- a/routes/games/game_toggle_public_played.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import request, redirect, url_for - -import db - -from core.flask_app import app - - -@app.route("/game//toggle-public-played", methods=["POST"]) -def game_toggle_public_played(slug): - """Bascule "Publier en ligne" (voir db/games/is_public_played.py) — - active/désactive la route publique /jouer/ (routes/public_play/), - jouable par n'importe quel visiteur anonyme avec sa propre partie - (état par joueur, Phase 1). Distinct de "Publier" (export .zip - exécutable mono-joueur, voir routes/publish/publish_game.py).""" - db.set_public_played(slug, request.form.get("value") == "1") - return redirect(request.referrer or url_for("game_dashboard", slug=slug)) diff --git a/routes/games/games_new.py b/routes/games/games_new.py index a45fa912..72d354b6 100644 --- a/routes/games/games_new.py +++ b/routes/games/games_new.py @@ -1,4 +1,4 @@ -from flask import request, redirect, url_for +from flask import g, request, redirect, url_for import db @@ -10,8 +10,12 @@ def games_new(): name = request.form.get("name", "").strip() if not name: return redirect(url_for("index")) - game_type = request.form.get("game_type", db.DEFAULT_GAME_TYPE) - if game_type not in ("document", "jeu_2d"): - game_type = db.DEFAULT_GAME_TYPE - slug = db.create_game(name, game_type=game_type) + # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un + # jeu n'a plus de type propre — aucun écran créé d'office ici, comme + # avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en + # choisissant "document" ou "jeu_2d" pour chacun). + # owner_folder (voir db/games/project_slug.py — structure de dossiers + # par utilisateur) : posé par core/auth_guard.py pour tout compte + # connecté, y compris l'admin qui utilise cette route. + slug = db.create_game(name, owner_folder=db.slugify(g.current_user["email"])) return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/onboarding/__init__.py b/routes/onboarding/__init__.py new file mode 100644 index 00000000..3a6b157f --- /dev/null +++ b/routes/onboarding/__init__.py @@ -0,0 +1 @@ +from . import onboarding_new diff --git a/routes/onboarding/onboarding_new.py b/routes/onboarding/onboarding_new.py new file mode 100644 index 00000000..d0f04212 --- /dev/null +++ b/routes/onboarding/onboarding_new.py @@ -0,0 +1,66 @@ +from flask import g, redirect, render_template, request, url_for + +import auth +import db +import screens + +from core.flask_app import app + + +@app.route("/onboarding", methods=["GET", "POST"]) +def onboarding_new(): + """Point d'entrée UNIQUE pour créer un jeu — comportement SYSTÉMATIQUE + à chaque création, pas une formalité réservée à la toute première + inscription : chaque projet (celui d'un compte "user" comme n'importe + lequel des projets d'un admin) choisit son propre type ici. Prépare + aussi le futur multi-projet payant (plusieurs jeux de types + différents pour un même compte "user"). + + Un compte "user" fraîchement inscrit n'a plus de projet créé d'office + (voir routes/auth/register_2fa.py) : core/auth_guard.py le redirige + ici tant qu'il n'a pas encore de project_slug — et n'en aura jamais + qu'UN SEUL (voir core/auth_guard.py). Un admin, lui, n'a PAS de + project_slug dédié (illimité, voir create_user.py) : repasse ici à + volonté pour créer un projet de PLUS, jamais redirigé ailleurs. + + Choisit un type (voir db.ONBOARDING_TYPES) puis nomme son jeu — crée + le projet ET son premier écran (du "kind" correspondant), puis envoie + vers son tableau de bord (routes/games/game_dashboard.py y rend une + version SIMPLIFIÉE pour un type restreint — quiz/embranchement/rpg — + ou la version complète pour "custom", même route pour tous).""" + user = g.current_user + if user["role"] != "admin" and user.get("project_slug"): + # Retour en arrière après coup (l'onboarding a déjà été fait) — + # ne recrée rien, renvoie directement vers son tableau de bord. Ne + # s'applique PAS à un admin (illimité, jamais "déjà fait" une fois + # pour toutes). + return redirect(url_for("game_dashboard", slug=user["project_slug"])) + + error = None + if request.method == "POST": + onboarding_type = request.form.get("onboarding_type", "") + name = request.form.get("name", "").strip() + if onboarding_type not in db.ONBOARDING_TYPES: + error = "Choisis un type de jeu." + elif not name: + error = "Donne un nom à ton jeu." + else: + return redirect(_create_project_for_user(user, onboarding_type, name)) + + return render_template( + "onboarding/onboarding_new.html", + onboarding_types=db.ONBOARDING_TYPES, error=error, + ) + + +def _create_project_for_user(user, onboarding_type, name): + slug = db.create_game(name, owner_folder=db.slugify(user["email"])) + db.set_onboarding_type(slug, onboarding_type) + if user["role"] != "admin": + # project_slug = LE seul projet d'un compte "user" (voir + # core/auth_guard.py) — un admin n'utilise pas cette colonne, ses + # projets se retrouvent via db.list_games() comme avant. + auth.set_project_slug(user["id"], slug) + screen_kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"] + screens.create_screen(slug, name, kind=screen_kind) + return url_for("game_dashboard", slug=slug) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index fed186ca..f1b9a9fc 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -18,8 +18,11 @@ def game_play(slug): payload = screens.full_game_payload(slug) return render_template( "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + asset_url=lambda filename: url_for("static", filename=filename), runtime_payload_url=url_for("runtime_payload", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999), + flow_node_run_score_url=url_for("flow_node_run_score", slug=slug, node_id=999999), + flow_node_run_status_url=url_for("flow_node_run_status", slug=slug, node_id=999999), flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py deleted file mode 100644 index 6984c1bb..00000000 --- a/routes/public_play/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from . import ( - game_play_public, - runtime_payload_public, - flow_node_run_data_public, - flow_node_run_variable_public, - flow_node_run_add_row_public, -) diff --git a/routes/public_play/flow_node_run_add_row_public.py b/routes/public_play/flow_node_run_add_row_public.py deleted file mode 100644 index 1f4ae06d..00000000 --- a/routes/public_play/flow_node_run_add_row_public.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import jsonify, abort - -import db -import screens - -from core.flask_app import app -from core.player_identity import get_or_create_player_id, set_player_id_cookie - - -@app.route("/jouer//flow/nodes//run-add-row", methods=["POST"]) -def flow_node_run_add_row_public(slug, node_id): - """Miroir public de routes/flow/flow_node_run_add_row.py (aperçu - créateur) — voir routes/public_play/game_play_public.py pour le - principe général.""" - if not db.is_public_played(slug): - abort(404) - player_id, is_new = get_or_create_player_id() - node = screens.get_flow_node(slug, node_id) - if not node or node.get("node_type") != "action" or node.get("action_type") != "ajouter_ligne": - return jsonify({"ok": False}), 400 - row_id = screens.apply_add_row_action(slug, node, player_id) - resp = app.make_response(jsonify({"ok": row_id is not None, "row_id": row_id})) - if is_new: - set_player_id_cookie(resp, player_id) - return resp diff --git a/routes/public_play/flow_node_run_data_public.py b/routes/public_play/flow_node_run_data_public.py deleted file mode 100644 index 605bac16..00000000 --- a/routes/public_play/flow_node_run_data_public.py +++ /dev/null @@ -1,33 +0,0 @@ -from flask import jsonify, request, abort - -import db -import screens - -from core.flask_app import app -from core.player_identity import get_or_create_player_id, set_player_id_cookie - - -@app.route("/jouer//flow/nodes//run-data", methods=["POST"]) -def flow_node_run_data_public(slug, node_id): - """Miroir public de routes/flow/flow_node_run_data.py (aperçu créateur) - — voir routes/public_play/game_play_public.py pour le principe - général. `player_id` (le cookie du visiteur) est passé à - apply_data_action pour qu'un objet per_player agisse sur SA ligne.""" - if not db.is_public_played(slug): - abort(404) - player_id, is_new = get_or_create_player_id() - node = screens.get_flow_node(slug, node_id) - if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": - return jsonify({"ok": False}), 400 - if node.get("target_row_id") == screens.CLICKED_ROW_ID: - body = request.get_json(silent=True) or {} - node = {**node, "target_row_id": body.get("clicked_row_id")} - elif node.get("target_row_id") == screens.LAST_INSERTED_ROW_ID: - # Phase 4 — voir routes/flow/flow_node_run_data.py. - body = request.get_json(silent=True) or {} - node = {**node, "target_row_id": body.get("last_inserted_row_id")} - ok = screens.apply_data_action(slug, node, player_id) - resp = app.make_response(jsonify({"ok": ok})) - if is_new: - set_player_id_cookie(resp, player_id) - return resp diff --git a/routes/public_play/flow_node_run_variable_public.py b/routes/public_play/flow_node_run_variable_public.py deleted file mode 100644 index cb0ad632..00000000 --- a/routes/public_play/flow_node_run_variable_public.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import jsonify, abort - -import db -import screens - -from core.flask_app import app -from core.player_identity import get_or_create_player_id, set_player_id_cookie - - -@app.route("/jouer//flow/nodes//run-variable", methods=["POST"]) -def flow_node_run_variable_public(slug, node_id): - """Miroir public de routes/flow/flow_node_run_variable.py (aperçu - créateur) — voir routes/public_play/game_play_public.py pour le - principe général.""" - if not db.is_public_played(slug): - abort(404) - player_id, is_new = get_or_create_player_id() - node = screens.get_flow_node(slug, node_id) - if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable": - return jsonify({"ok": False}), 400 - ok = screens.apply_variable_action(slug, node, player_id) - resp = app.make_response(jsonify({"ok": ok})) - if is_new: - set_player_id_cookie(resp, player_id) - return resp diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py deleted file mode 100644 index b142810d..00000000 --- a/routes/public_play/game_play_public.py +++ /dev/null @@ -1,37 +0,0 @@ -import json - -from flask import render_template, url_for, abort - -import db -import screens - -from core.flask_app import app -from core.player_identity import get_or_create_player_id, set_player_id_cookie - - -@app.route("/jouer/") -def game_play_public(slug): - """Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) — - distincte de /game//play (aperçu créateur, connecté, INCHANGÉE) : - un vrai visiteur anonyme, identifié par un cookie (voir - core/player_identity.py), avec sa propre partie (variables/objets - per_player, voir db/global_vars/, db/rows/). 404 si le créateur n'a - pas explicitement basculé "Publier en ligne" dans son tableau de bord - — jamais exposé par défaut.""" - if not db.is_public_played(slug): - abort(404) - game = db.game_meta(slug) - player_id, is_new = get_or_create_player_id() - payload = screens.full_game_payload(slug, player_id) - resp = app.make_response( - render_template( - "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), - runtime_payload_url=url_for("runtime_payload_public", slug=slug), - flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999), - flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999), - flow_node_run_add_row_url=url_for("flow_node_run_add_row_public", slug=slug, node_id=999999), - ) - ) - if is_new: - set_player_id_cookie(resp, player_id) - return resp diff --git a/routes/public_play/runtime_payload_public.py b/routes/public_play/runtime_payload_public.py deleted file mode 100644 index 867706ea..00000000 --- a/routes/public_play/runtime_payload_public.py +++ /dev/null @@ -1,20 +0,0 @@ -from flask import jsonify, abort - -import db -import screens - -from core.flask_app import app -from core.player_identity import get_or_create_player_id, set_player_id_cookie - - -@app.route("/jouer//runtime-payload") -def runtime_payload_public(slug): - """Miroir public de routes/flow/runtime_payload.py (aperçu créateur) — - voir routes/public_play/game_play_public.py pour le principe général.""" - if not db.is_public_played(slug): - abort(404) - player_id, is_new = get_or_create_player_id() - resp = app.make_response(jsonify(screens.full_game_payload(slug, player_id))) - if is_new: - set_player_id_cookie(resp, player_id) - return resp diff --git a/routes/publish/__init__.py b/routes/publish/__init__.py index 4961124c..18515f38 100644 --- a/routes/publish/__init__.py +++ b/routes/publish/__init__.py @@ -1 +1 @@ -from . import publish_game +from . import export_scorm diff --git a/routes/publish/export_scorm.py b/routes/publish/export_scorm.py new file mode 100644 index 00000000..b5386e6e --- /dev/null +++ b/routes/publish/export_scorm.py @@ -0,0 +1,27 @@ +import os + +from flask import after_this_request, send_file + +import db +from publish.build_scorm_package import build_scorm_zip + +from core.flask_app import app + + +@app.route("/game//export-scorm", methods=["POST"]) +def export_scorm(slug): + """Construit et renvoie le paquet Web/SCORM autonome du jeu (voir + publish/build_scorm_package.py) — même garde d'accès que + /game//publish (core/auth_guard.py).""" + zip_path, game_name = build_scorm_zip(slug) + + @after_this_request + def _cleanup(response): + try: + os.remove(zip_path) + except OSError: + pass + return response + + download_name = f"{db.slugify(game_name)}-scorm.zip" + return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip") diff --git a/routes/publish/publish_game.py b/routes/publish/publish_game.py deleted file mode 100644 index 7cd043af..00000000 --- a/routes/publish/publish_game.py +++ /dev/null @@ -1,28 +0,0 @@ -import os - -from flask import after_this_request, send_file - -import db -from publish.build_package import build_game_zip - -from core.flask_app import app - - -@app.route("/game//publish", methods=["POST"]) -def publish_game(slug): - """Construit et renvoie le zip exécutable autonome du jeu (voir - publish/build_package.py) — protégée par la même garde d'accès que - toutes les autres routes /game//... (core/auth_guard.py), - aucune vérification supplémentaire nécessaire ici.""" - zip_path, game_name = build_game_zip(slug) - - @after_this_request - def _cleanup(response): - try: - os.remove(zip_path) - except OSError: - pass - return response - - download_name = f"{db.slugify(game_name)}.zip" - return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip") diff --git a/routes/quests/__init__.py b/routes/quests/__init__.py new file mode 100644 index 00000000..95519fe5 --- /dev/null +++ b/routes/quests/__init__.py @@ -0,0 +1 @@ +from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues diff --git a/routes/quests/quest_delete.py b/routes/quests/quest_delete.py new file mode 100644 index 00000000..c726589d --- /dev/null +++ b/routes/quests/quest_delete.py @@ -0,0 +1,11 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//quests//delete", methods=["POST"]) +def quest_delete(slug, quest_id): + db.delete_quest(slug, quest_id) + return jsonify({"ok": True}) diff --git a/routes/quests/quest_dialogues.py b/routes/quests/quest_dialogues.py new file mode 100644 index 00000000..2f318de1 --- /dev/null +++ b/routes/quests/quest_dialogues.py @@ -0,0 +1,17 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests//dialogues", methods=["POST"]) +def quest_dialogues(slug, quest_id): + """Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) — + le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet + complet à chaque modification plutôt qu'une réplique isolée.""" + body = request.get_json(silent=True) or {} + dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues")) + if dialogues is None: + return jsonify({"error": "quête introuvable"}), 404 + return jsonify({"dialogues": dialogues}) diff --git a/routes/quests/quest_new.py b/routes/quests/quest_new.py new file mode 100644 index 00000000..a7b33ebc --- /dev/null +++ b/routes/quests/quest_new.py @@ -0,0 +1,12 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests/new", methods=["POST"]) +def quest_new(slug): + body = request.get_json(silent=True) or {} + quest = db.create_quest(slug, body.get("title")) + return jsonify({"quest": quest}) diff --git a/routes/quests/quest_update.py b/routes/quests/quest_update.py new file mode 100644 index 00000000..2c922c82 --- /dev/null +++ b/routes/quests/quest_update.py @@ -0,0 +1,14 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests/", methods=["POST"]) +def quest_update(slug, quest_id): + body = request.get_json(silent=True) or {} + quest = db.update_quest(slug, quest_id, body) + if quest is None: + return jsonify({"error": "quête introuvable"}), 404 + return jsonify({"quest": quest}) diff --git a/routes/quests/quests_list_json.py b/routes/quests/quests_list_json.py new file mode 100644 index 00000000..35fd9167 --- /dev/null +++ b/routes/quests/quests_list_json.py @@ -0,0 +1,15 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//quests/list.json") +def quests_list_json(slug): + """Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir + collision-rules-editor.js) pour lister les quêtes existantes sans + quitter la modale déjà ouverte (l'onglet "🗺️ Quêtes" lui-même reçoit + déjà sa liste via QUESTS_JSON, voir routes/scenes/scene_edit_view.py — + cette route ne sert qu'à la RAFRAÎCHIR depuis le contexte collision).""" + return jsonify({"quests": db.list_quests(slug)}) diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index 216ef0bc..8531ff69 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -3,4 +3,8 @@ from . import ( scene_object_geometry, scene_object_delete, scene_object_personnage_data, + scene_object_commands, + scene_object_role, + scene_object_collision, + scene_object_collision_rules, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 43d7c93b..75d1fa0d 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) @@ -22,18 +23,49 @@ def render_scene_edit(slug, screen_id, game): o["rendered_html"] = screens.render_scene_object(o) selected_id = request.args.get("selected", type=int) personnage_data = None + personnage_commands = None + personnage_role = None + collision_settings = None selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None - if selected_obj and selected_obj["kind"] == "personnage": - personnage_data = screens.resolve_personnage_data(selected_obj) + if selected_obj: + collision_settings = screens.resolve_collision_settings(selected_obj) + if selected_obj["kind"] == "personnage": + personnage_data = screens.resolve_personnage_data(selected_obj) + personnage_commands = screens.resolve_personnage_commands(selected_obj) + personnage_role = screens.resolve_personnage_role(selected_obj) + _DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"} object_labels = { - o["id"]: (o.get("name") or ("Personnage" if o["kind"] == "personnage" else "Décor")) + o["id"]: (o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor")) for o in objects } element_animations_map = { o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage" } + # "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet + # ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont + # TOUJOURS les AUTRES objets qui réagissent à SA présence, voir + # screens/rendering/collision_rules.py) — construit ici plutôt que + # réutiliser `objects` tel quel, pour ne poser resolve_personnage_role + # qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet + # SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages). + collision_rule_objects = [] + for o in objects: + if o["kind"] == "fond": + continue + role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None + if role == "joueur": + continue + collision_rule_objects.append({ + "id": o["id"], + "name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"), + "kind": o["kind"], + "role": role, + "thumbnail_html": o["rendered_html"], + "rules": screens.resolve_collision_rules(o), + }) + flow_blocks = screens.list_flow_blocks(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_edges = screens.list_flow_edges(slug, screen_id) @@ -51,13 +83,21 @@ def render_scene_edit(slug, screen_id, game): # "Bibliothèque de sprites animaux CraftPix"). sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY sprite_library_families = screens.sprite_gallery_families(sprite_library) + # Images de fond CraftPix (screens.BACKGROUND_LIBRARY) : même règle + # d'accès que les sprites animaux — réservées au rôle admin. + background_library = screens.BACKGROUND_LIBRARY if g.current_user["role"] == "admin" else {} return render_template( "scene_edit.html", game=game, screen=screen, all_screens=all_screens, objects=objects, selected_id=selected_id, personnage_data=personnage_data, + personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS, + personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE, + personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS, + collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS, sprite_library=sprite_library, sprite_library_families=sprite_library_families, sprite_library_json=json.dumps(sprite_library), + background_library=background_library, element_animations_map_json=json.dumps(element_animations_map), element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES, element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS), @@ -68,6 +108,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), @@ -83,4 +125,13 @@ def render_scene_edit(slug, screen_id, game): definitions=db.list_definitions(slug), definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}), definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}), + collision_rule_objects_json=json.dumps(collision_rule_objects), + collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, + collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, + # Éditeur de quêtes (voir db/quests/) : préchargées ici pour que + # collisionRuleActionSummary (collision-rules-editor.js) affiche + # tout de suite le TITRE de la quête référencée par une règle + # existante, sans attendre l'ouverture de l'assistant "+ Action" + # (seul moment où questPickerHtml irait la chercher sinon). + quests_json=json.dumps(db.list_quests(slug)), ) diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py index 4786ea93..e20131e9 100644 --- a/routes/scenes/scene_object_add.py +++ b/routes/scenes/scene_object_add.py @@ -3,7 +3,7 @@ from flask import request, redirect, url_for import screens from core.flask_app import app -from core.sprite_gate import forbid_admin_only_character +from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_background @app.route("/game//screens//scene-objects/add", methods=["POST"]) @@ -13,9 +13,14 @@ def scene_object_add(slug, screen_id): add_scene_object.py).""" kind = request.form.get("kind", "personnage") forge_character = request.form.get("forge_character") + background_slug = request.form.get("background_slug") if forge_character: forbid_admin_only_character(forge_character) - new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character) + if background_slug: + forbid_admin_only_background(background_slug) + new_id = screens.add_scene_object( + slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug, + ) # Même route que l'éditeur document (/game//screens//edit, # endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la # vue de scène selon game_type, voir routes/screens/screen_edit.py. diff --git a/routes/scenes/scene_object_collision.py b/routes/scenes/scene_object_collision.py new file mode 100644 index 00000000..2dcf2f81 --- /dev/null +++ b/routes/scenes/scene_object_collision.py @@ -0,0 +1,34 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//collision", methods=["POST"]) +def scene_object_collision(slug, object_id): + """Boîte de collision d'un objet de scène (voir "🧱 Collision" dans + templates/scene_edit.html, static/js/play/conditions.js:: + elementsOverlap pour son usage au jeu).""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + + settings = {"enabled": bool(body.get("enabled", True))} + settings["shape"] = body.get("shape") if body.get("shape") in screens.COLLISION_SHAPES else "rectangle" + for key in ("width", "height"): + try: + value = body.get(key) + settings[key] = float(value) if value not in (None, "") else None + except (TypeError, ValueError): + settings[key] = None + for key in ("offset_x", "offset_y"): + try: + settings[key] = float(body.get(key) or 0) + except (TypeError, ValueError): + settings[key] = 0 + + screens.set_scene_object_collision(slug, object_id, settings) + obj = screens.get_scene_object(slug, object_id) + return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)}) diff --git a/routes/scenes/scene_object_collision_rules.py b/routes/scenes/scene_object_collision_rules.py new file mode 100644 index 00000000..d7c44159 --- /dev/null +++ b/routes/scenes/scene_object_collision_rules.py @@ -0,0 +1,21 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//collision-rules", methods=["POST"]) +def scene_object_collision_rules(slug, object_id): + """Liste complète des règles "déclencheur -> action" d'un objet (voir + "🧩 Collision" dans templates/scene_edit.html) — le client poste + toujours la liste ENTIÈRE (ajout/suppression déjà appliqués côté + client), jamais une règle isolée, même patron que + scene_object_commands.py pour "extra".""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + rules = screens.sanitize_collision_rules(body.get("rules")) + screens.set_scene_object_collision_rules(slug, object_id, rules) + return jsonify({"ok": True, "rules": rules}) diff --git a/routes/scenes/scene_object_commands.py b/routes/scenes/scene_object_commands.py new file mode 100644 index 00000000..2c4967de --- /dev/null +++ b/routes/scenes/scene_object_commands.py @@ -0,0 +1,40 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//commandes", methods=["POST"]) +def scene_object_commands(slug, object_id): + """Touches de déplacement/interaction + blocage d'axe d'un personnage + (voir templates/scene_edit.html, section "Commandes" du panneau + Personnage, et static/js/play/personnage-controller.js pour leur + usage au jeu) — mirror de scene_object_personnage_data.py, un + attribut JSON distinct.""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + + defaults = screens.DEFAULT_PERSONNAGE_COMMANDS + commands = {} + for key in ("haut", "bas", "gauche", "droite", "interagir"): + # Jamais de .strip() ici : " " (Espace) est une valeur ev.key + # parfaitement valide côté client (voir personnage-controller.js) + # pour "interagir" — un strip() la réduirait à tort à "" et la + # ferait retomber sur "e" par défaut. + value = body.get(key) + commands[key] = value if isinstance(value, str) and value else defaults[key] + commands["axe"] = body.get("axe") if body.get("axe") in ("libre", "horizontal", "vertical") else "libre" + try: + vitesse = float(body.get("vitesse", defaults["vitesse"])) + except (TypeError, ValueError): + vitesse = defaults["vitesse"] + commands["vitesse"] = max(1, min(20, vitesse)) + # Touches supplémentaires (sauter, attaquer...) — voir "Animations + # supplémentaires" dans le panneau Commandes, templates/scene_edit.html. + commands["extra"] = screens.sanitize_extra_commands(body.get("extra")) + + screens.set_scene_object_commands(slug, object_id, commands) + return jsonify({"ok": True, "commands": commands}) diff --git a/routes/scenes/scene_object_role.py b/routes/scenes/scene_object_role.py new file mode 100644 index 00000000..83dbbd88 --- /dev/null +++ b/routes/scenes/scene_object_role.py @@ -0,0 +1,22 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//role", methods=["POST"]) +def scene_object_role(slug, object_id): + """Rôle d'un personnage (joueur/ennemie/pnj — voir "🏷️ Rôle" dans le + panneau Personnage, templates/scene_edit.html) : seul un personnage + "joueur" est déplacé/animé automatiquement au clavier (voir + static/js/play/personnage-controller.js).""" + body = request.get_json(silent=True) or {} + obj = screens.get_scene_object(slug, object_id) + if not obj: + return jsonify({"error": "objet introuvable"}), 404 + role = body.get("role") + if role not in screens.PERSONNAGE_ROLES: + role = screens.DEFAULT_PERSONNAGE_ROLE + screens.set_scene_object_role(slug, object_id, role) + return jsonify({"ok": True, "role": role}) diff --git a/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/routes/screens/screens_new.py b/routes/screens/screens_new.py index 7566471f..cab0bdc1 100644 --- a/routes/screens/screens_new.py +++ b/routes/screens/screens_new.py @@ -1,5 +1,6 @@ from flask import request, redirect, url_for +import db import screens from core.flask_app import app @@ -8,7 +9,19 @@ from core.flask_app import app @app.route("/game//screens/new", methods=["POST"]) def screens_new(slug): name = request.form.get("name", "").strip() + if db.is_restricted(slug): + # Dashboard simplifié (voir routes/games/game_dashboard.py) : pas + # de sélecteur de type dans son formulaire "+ Ajouter un écran" — + # imposé par le projet (jeu_2d pour un RPG, document pour les + # autres), même valeur pour TOUS ses écrans (voir + # db/games/game_type_catalog.py). + onboarding_type = db.get_onboarding_type(slug) + kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"] + else: + kind = request.form.get("kind", "document") + if kind not in ("document", "jeu_2d"): + kind = "document" if name: - screen_id = screens.create_screen(slug, name) + screen_id = screens.create_screen(slug, name, kind=kind) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) return redirect(url_for("game_dashboard", slug=slug, tab="screens")) diff --git a/screens/__init__.py b/screens/__init__.py index d39e440f..169c69c6 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -67,6 +67,11 @@ from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY} ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY) from .labels.sprite_gallery import sprite_gallery_families +from .labels.background_library import BACKGROUND_LIBRARY +# Un seul pack aujourd'hui, licence CraftPix — tout BACKGROUND_LIBRARY est +# admin-only (voir core/sprite_gate.py::forbid_admin_only_background), +# jamais de version "publique" comme pour les personnages Kenney. +ADMIN_ONLY_BACKGROUND_SLUGS = set(BACKGROUND_LIBRARY) from .rendering.list_used_forge_characters import list_used_forge_characters from .flow.constants import ( @@ -76,6 +81,13 @@ from .flow.constants import ( from .rendering.render_element_html import render_element_html from .rendering.trigger_for import _trigger_for from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame +from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE +from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES +from .rendering.collision_rules import ( + resolve_collision_rules, sanitize_collision_rules, + ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX, +) +from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands from .screens_repo.ensure_schema import ensure_schema from .screens_repo.list_screens import list_screens @@ -109,6 +121,10 @@ 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.set_scene_object_commands import set_scene_object_commands +from .scenes.set_scene_object_role import set_scene_object_role +from .scenes.set_scene_object_collision import set_scene_object_collision +from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules from .scenes.render_scene_object import render_scene_object from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES from .elements.element_label import element_label @@ -144,6 +160,8 @@ from .flow.blocks.blocks_view import blocks_view from .data_actions.apply_data_action import apply_data_action from .data_actions.apply_variable_action import apply_variable_action +from .data_actions.apply_score_action import apply_score_action +from .data_actions.apply_status_action import apply_status_action from .data_actions.apply_add_row_action import apply_add_row_action from .animations.ensure_animation_schema import ensure_animation_schema diff --git a/screens/data_actions/apply_score_action.py b/screens/data_actions/apply_score_action.py new file mode 100644 index 00000000..e08fee9c --- /dev/null +++ b/screens/data_actions/apply_score_action.py @@ -0,0 +1,23 @@ +import db + +from .compute_operation import compute_new_value + + +def apply_score_action(slug, action, player_id=db.PLAYER_SHARED): + """Exécute au moment du clic (mode jouable) une action + "modifier_score" — même vocabulaire d'opérations que + apply_variable_action.py (DATA_OPERATIONS), appliqué au score de CE + joueur (voir db/scoring/) plutôt qu'à une variable globale. Le score + est TOUJOURS numérique (pas de "type" à lire au préalable, contrairement + à une variable — is_decimal=True partout, un score entier reste exact + en float pour de petits nombres).""" + operation = action.get("data_operation") + raw_value = action.get("data_value") + if not operation: + return False + current = db.get_score(slug, player_id)["score"] or 0 + try: + new_value = compute_new_value(operation, current, raw_value, True) + except ValueError: + return False + return db.set_score_value(slug, new_value, player_id) diff --git a/screens/data_actions/apply_status_action.py b/screens/data_actions/apply_status_action.py new file mode 100644 index 00000000..47719d61 --- /dev/null +++ b/screens/data_actions/apply_status_action.py @@ -0,0 +1,13 @@ +import db + + +def apply_status_action(slug, action, player_id=db.PLAYER_SHARED): + """Exécute au moment du clic (mode jouable) une action + "definir_statut_partie" — réutilise data_value (déjà un simple champ + texte générique sur le nœud action, comme pour "attendre") pour porter + la clé du statut choisi (voir db.SCORE_STATUS_LABELS), plutôt qu'une + nouvelle colonne de nœud dédiée.""" + status = action.get("data_value") + if not status: + return False + return db.set_status(slug, status, player_id) diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index 5d78197c..3b5620cf 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -5,6 +5,8 @@ ACTION_TYPE_LABELS = { "modifier_element": "Modifier un élément", "modifier_donnee": "Modifier une donnée d'un objet", "modifier_variable": "Modifier une variable globale", + "modifier_score": "Modifier le score", + "definir_statut_partie": "Définir le statut de la partie", "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", "activer_onglet": "Afficher cet élément, masquer tous ses frères", "attendre": "Attendre quelques secondes avant de continuer", diff --git a/screens/labels/animal_sprite_library.py b/screens/labels/animal_sprite_library.py index b90bdf81..b5d2afb8 100644 --- a/screens/labels/animal_sprite_library.py +++ b/screens/labels/animal_sprite_library.py @@ -24,7 +24,7 @@ def _load_manifest(): except FileNotFoundError: # Poste de dev sans les assets animaux copiés (script pas encore # lancé), ou paquet exporté pour un jeu qui n'utilise aucun - # personnage animal (voir publish/build_package.py, copie + # personnage animal (voir publish/build_scorm_package.py, copie # sélective) : aucun personnage animal disponible, pas une erreur. return {} diff --git a/screens/labels/background_library.py b/screens/labels/background_library.py new file mode 100644 index 00000000..ced6744e --- /dev/null +++ b/screens/labels/background_library.py @@ -0,0 +1,52 @@ +import json +import os + +# Bibliothèque d'images de fond CraftPix — RÉSERVÉE au rôle admin (voir +# core/sprite_gate.py, routes/scenes/scene_object_add.py), même règle que +# la bibliothèque de sprites animaux (screens/labels/animal_sprite_library.py) : +# licence CraftPix, interdiction de rendre ces images utilisables par un +# compte "user". Générée une fois par +# scripts/generate_background_manifest.py depuis assets/background// +# (pack source, non versionné) vers static/backgrounds// +# .png, avec un manifest JSON portant les dimensions RÉELLES +# (largeur/hauteur) de chaque image — utilisées comme taille par défaut de +# l'objet de scène "fond" posé (voir screens/scenes/add_scene_object.py), +# modifiable ensuite comme n'importe quel objet (panneau "Position & taille"). +_MANIFEST_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "static", "backgrounds", "manifest.json", +) + + +def _load_manifest(): + try: + with open(_MANIFEST_PATH, encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + # Poste de dev sans les images de fond copiées (script pas encore + # lancé), ou paquet exporté pour un jeu qui n'utilise aucun fond + # CraftPix (voir publish/build_scorm_package.py, à étendre pour + # copier sélectivement static/backgrounds/ le jour où un export + # utilise ce widget) : aucun fond disponible, pas une erreur. + return {} + + +def _build_library(): + manifest = _load_manifest() + library = {} + for pack_slug, pack in manifest.items(): + pack_label = pack["label"] + for image_name, dims in pack["images"].items(): + slug = f"{pack_slug}-{image_name}" + library[slug] = { + "label": f"{pack_label} — {image_name.replace('_', ' ')}", + "url": f"/static/backgrounds/{pack_slug}/{image_name}.png", + "width": dims["width"], + "height": dims["height"], + "pack": pack_slug, + "pack_label": pack_label, + } + return library + + +BACKGROUND_LIBRARY = _build_library() diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 6175f705..14930655 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -6,42 +6,71 @@ from ..flow.list_flow_nodes import list_flow_nodes from ..flow.list_flow_edges import list_flow_edges from ..animations.list_animation_clips import list_animation_clips from ..element_types.list_element_types import list_element_types +from ..element_types.load_template_tree import _load_template_tree from ..rendering.personnage_data import resolve_personnage_animations +from ..rendering.personnage_commands import resolve_personnage_commands +from ..rendering.personnage_role import resolve_personnage_role +from ..rendering.collision_settings import resolve_collision_settings +from ..rendering.collision_rules import resolve_collision_rules from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object def full_game_payload(slug, player_id=db.PLAYER_SHARED): """Toutes les données nécessaires au runtime JS de la page de jeu jouable - (/game//play, ou /jouer/ — voir routes/public_play/) : chaque - écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de - données, lus en direct), le graphe de logique (nœuds + fils) de chaque - écran, et un instantané des données du jeu (avec le type de chaque - champ, nécessaire pour évaluer une condition correctement) — un seul - aller-retour serveur, ensuite tout se joue côté client (aucune - navigation ne recharge la page, sauf pour appliquer une action "modifier - une donnée", qui doit passer par le serveur). + (/game//play, ou le paquet Web/SCORM exporté — voir + publish/build_scorm_package.py) : chaque écran avec ses éléments (HTML + déjà généré, y compris les Répéteurs de données, lus en direct), le + graphe de logique (nœuds + fils) de chaque écran, et un instantané des + données du jeu (avec le type de chaque champ, nécessaire pour évaluer + une condition correctement) — un seul aller-retour serveur, ensuite + tout se joue côté client (aucune navigation ne recharge la page, sauf + pour appliquer une action "modifier une donnée", qui doit passer par + le serveur). - player_id (état par joueur, Phase 1) : PLAYER_SHARED pour l'aperçu - créateur (comportement inchangé — une seule partie partagée pendant la - 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 %) ; + player_id (état par joueur, Phase 1) : toujours PLAYER_SHARED + aujourd'hui (une seule partie partagée, pour la conception comme pour + un paquet exporté) — le paramètre reste générique pour tout objet/ + variable per_player (voir db/global_vars/, db/rows/), qui renvoie + alors la valeur propre à CE joueur.""" + # 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) + # Déplacement/animation automatiques (voir + # static/js/play/personnage-controller.js) : chaque + # personnage porte ses touches et son rôle déjà résolus + # (défauts comblés) pour que le runtime client n'ait + # aucune logique de repli à dupliquer — seul un + # personnage "joueur" est déplacé/suivi par la caméra. + if o["kind"] == "personnage": + o["personnage_commandes"] = resolve_personnage_commands(o) + o["personnage_role"] = resolve_personnage_role(o) + # Boîte de collision (voir static/js/play/conditions.js:: + # elementsOverlap) : posée sur TOUT objet de scène, pas + # seulement "personnage" — un décor/fond peut aussi servir + # d'obstacle. + o["collision"] = resolve_collision_settings(o) + # Règles "à la collision/dans un périmètre -> action" (voir + # "🧩 Collision", templates/scene_edit.html, et + # static/js/play/collision-rules-controller.js pour leur + # exécution) — jamais sur "fond" (jamais une cible), ni sur + # le personnage "joueur" lui-même (ce sont TOUJOURS les + # AUTRES objets qui réagissent à SA présence, jamais + # l'inverse). + if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"): + o["collision_rules"] = resolve_collision_rules(o) payload_screens.append({**s, "elements": objects}) else: elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id) @@ -80,7 +109,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) @@ -95,12 +124,37 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): for d in definitions: full = db.get_definition(slug, d["id"]) rows = db.list_rows(slug, full, player_id) - cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]} + # Un champ "relation" vit dans une colonne SQL "_id", jamais + # sous son nom "propre" (voir _field_column dans + # filter_repeater_rows.py, la même règle) — sans ce cas particulier, + # un champ relation lisait toujours une colonne inexistante et + # renvoyait systématiquement None ici. Inoffensif EN LIGNE (cette + # snapshot n'est jamais utilisée pour un filtre/une Donnée liée : ces + # derniers requêtent la base fraîche via filter_repeater_rows.py) — + # mais fatal pour le port hors ligne (export Web/SCORM), qui n'a + # QUE cette snapshot, aucune base à requêter (bug signalé par un + # utilisateur : un filtre sur un champ relation ne matchait jamais + # rien une fois exporté, laissant "{{champ}}" affiché tel quel). + cols = {} + for f in full["fields"]: + col = db.slugify(f["name"]).replace("-", "_") + if f["type"] == "relation": + col += "_id" + cols[f["name"]] = col data[str(d["id"])] = [ {**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]} for r in rows ] - fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]] + # min_value/max_value (2.2 — bornage automatique, voir + # apply_data_action.py) : nécessaires ici pour que le port + # client-side de l'export Web/SCORM (static/js/play/offline/, + # voir le plan "port complet du runtime jouable côté navigateur") + # puisse reproduire le même bornage sans aller-retour serveur — + # inoffensif pour le runtime en ligne, qui ignore ces deux clés. + fields_meta[str(d["id"])] = [ + {"name": f["name"], "type": f["type"], "min_value": f.get("min_value"), "max_value": f.get("max_value")} + for f in full["fields"] + ] # element_type_id -> id de son écran-modèle : nécessaire côté client (voir # collectAnimationClips() dans play.html) pour savoir, quand un élément de @@ -111,6 +165,21 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # modèle est utilisé ailleurs. element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)} + # Arbre complet (éléments + descendants) de l'écran-modèle de chaque + # élément de jeu réutilisable — voir screens/element_types/ + # load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu + # créateur ET jeu publié en ligne). Nécessaire ici pour que le port + # client-side de l'export Web/SCORM (static/js/play/offline/, + # rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de + # Répéteur) puisse le rejouer sans base de données — inoffensif pour + # le runtime en ligne, qui l'ignore. + element_type_templates = {} + for type_id_str, template_screen_id in element_types.items(): + if template_screen_id: + tree = _load_template_tree(slug, int(type_id_str)) + if tree: + element_type_templates[type_id_str] = tree + # Instantané des variables globales, pour qu'un nœud Condition puisse en # tester une côté CLIENT (evaluateConditionClause, templates/play.html) — # {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté @@ -122,8 +191,16 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): for v in db.list_global_variables_for_player(slug, player_id) } + # id de définition -> nom de l'objet (voir filter_repeater_rows.py côté + # serveur, static/js/play/offline/filter-repeater-rows.js côté port + # hors ligne) : nécessaire pour résoudre une référence "{{Objet.champ}}" + # (Répéteur/Donnée liée/Condition de visibilité) — data/fields_meta + # ci-dessus sont déjà indexés par id, jamais par nom. + definition_names = {str(d["id"]): d["name"] for d in definitions} + return { "screens": payload_screens, "flows": flows, "animations": animations, "element_types": element_types, "data": data, "fields_meta": fields_meta, "variables": variables, "personnage_animations": personnage_animations, + "definition_names": definition_names, "element_type_templates": element_type_templates, } diff --git a/screens/rendering/collision_rules.py b/screens/rendering/collision_rules.py new file mode 100644 index 00000000..330f60a2 --- /dev/null +++ b/screens/rendering/collision_rules.py @@ -0,0 +1,108 @@ +import json + +# Éditeur de collision (nouvel onglet dédié aux scènes 2D, voir +# templates/scene_edit.html) : chaque objet de scène (hors le personnage +# "joueur" — voir screens/rendering/personnage_role.py — et un "fond", +# jamais une cible/source de collision) porte une LISTE de règles +# "déclencheur -> action", construites carte par carte dans l'éditeur. +# +# Déclencheur : "collision" (chevauchement direct, voir +# static/js/play/conditions.js::forgeShapesOverlap) ou "perimetre" (le +# joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). +# +# Action (une seule par règle) : +# - "quete" : quete_id (id technique d'une quête — voir db/quests/, +# choisie/créée via l'assistant "+ Action" qui ouvre +# directement l'éditeur de quête, static/js/quests/ +# quest-editor.js) — une quête inexistante/supprimée +# depuis reste une référence orpheline plutôt qu'une +# contrainte SQL, comme le reste de ce moteur (voir +# evenement_id ci-dessous, même principe). +# - "attaque" : joue la pose "attack" du personnage si disponible (voir +# personnage-controller.js) — pas de système de combat +# (dégâts/PV...) pour l'instant, effet visuel seul. +# - "evenement" : evenement_id — réutilise TEL QUEL le système +# d'événements personnalisés existant (db.list_custom_events, +# window.dispatchGameEvent), la logique de flow d'un écran +# document peut donc déjà écouter ce déclenchement. +# - "interagir" : affiche "Appuie sur [touche]" (la touche "interagir" du +# joueur, voir personnage_commands.py) tant que la +# collision/le périmètre est actif, puis exécute +# `sub_action` (même forme qu'une action, SAUF +# "interagir" à nouveau — UN seul niveau d'imbrication, +# pas une récursion illimitée) à l'appui de la touche. +ACTION_TYPES = ["quete", "attaque", "evenement", "interagir"] +TRIGGER_TYPES = ["collision", "perimetre"] +DEFAULT_PERIMETRE_PX = 5 +# Profondeur max d'imbrication ("interagir" -> sous-action) — évite une +# structure sans fin construite par un JSON posté à la main. +_MAX_ACTION_DEPTH = 2 + + +def _sanitize_action(raw_action, depth=1): + if not isinstance(raw_action, dict): + return None + action_type = raw_action.get("type") + if action_type not in ACTION_TYPES: + return None + if action_type == "interagir" and depth >= _MAX_ACTION_DEPTH: + return None # un seul niveau d'imbrication autorisé + action = {"type": action_type} + if action_type == "quete": + try: + action["quete_id"] = int(raw_action.get("quete_id")) + except (TypeError, ValueError): + return None + elif action_type == "evenement": + try: + action["evenement_id"] = int(raw_action.get("evenement_id")) + except (TypeError, ValueError): + return None + elif action_type == "interagir": + sub_action = _sanitize_action(raw_action.get("sub_action"), depth=depth + 1) + if not sub_action: + return None + action["sub_action"] = sub_action + # "attaque" : aucun champ supplémentaire. + return action + + +def sanitize_collision_rules(raw_rules): + """Valide/nettoie une liste de règles postée par le client (voir + routes/scenes/scene_object_collision_rules.py) — une règle invalide + (déclencheur/action inconnus, imbrication trop profonde...) est + IGNORÉE plutôt que de faire planter la sauvegarde entière.""" + if not isinstance(raw_rules, list): + return [] + cleaned = [] + for raw_rule in raw_rules[:50]: # marge large, jamais illimité + if not isinstance(raw_rule, dict): + continue + trigger = raw_rule.get("trigger") + if trigger not in TRIGGER_TYPES: + continue + action = _sanitize_action(raw_rule.get("action")) + if not action: + continue + rule = {"trigger": trigger, "action": action} + if trigger == "perimetre": + try: + rule["perimetre_px"] = max(1, float(raw_rule.get("perimetre_px", DEFAULT_PERIMETRE_PX))) + except (TypeError, ValueError): + rule["perimetre_px"] = DEFAULT_PERIMETRE_PX + cleaned.append(rule) + return cleaned + + +def resolve_collision_rules(obj): + """Décode attributes._collision_rules en liste Python, jamais None — + un attribut absent/corrompu retombe sur aucune règle (comportement + inchangé pour tout objet déjà posé avant l'ajout de cet éditeur).""" + raw = (obj.get("attributes") or {}).get("_collision_rules") + if not raw: + return [] + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return [] + return sanitize_collision_rules(parsed) diff --git a/screens/rendering/collision_settings.py b/screens/rendering/collision_settings.py new file mode 100644 index 00000000..54c4d7ff --- /dev/null +++ b/screens/rendering/collision_settings.py @@ -0,0 +1,40 @@ +import json + +# Boîte de collision d'un objet de scène (voir screens/scenes/ +# set_scene_object_collision.py) : posée AUTOMATIQUEMENT sur chaque objet +# (rectangle, taille = la boîte de l'objet lui-même, centrée dessus, sans +# décalage — comportement inchangé pour une condition de collision +# existante, voir elementsOverlap() dans static/js/play/conditions.js), +# réglable ensuite dans le panneau de propriétés : activée/désactivée, +# forme (rectangle/cercle) et taille/décalage propres, pour un sprite très +# paddé (ex. les frames CraftPix) dont la silhouette réelle est bien plus +# petite que son canevas. +DEFAULT_COLLISION_SETTINGS = { + "enabled": True, "shape": "rectangle", "width": None, "height": None, + "offset_x": 0, "offset_y": 0, +} +COLLISION_SHAPES = ["rectangle", "cercle"] + + +def resolve_collision_settings(obj): + """Décode attributes._collision en dict Python, jamais None — un + attribut absent/corrompu retombe sur le défaut (boîte de collision = + la boîte entière de l'objet, comme si ce réglage n'existait pas).""" + raw = (obj.get("attributes") or {}).get("_collision") + settings = dict(DEFAULT_COLLISION_SETTINGS) + if raw: + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + parsed = None + if isinstance(parsed, dict): + for key in DEFAULT_COLLISION_SETTINGS: + if key in parsed: + settings[key] = parsed[key] + if settings["shape"] not in COLLISION_SHAPES: + settings["shape"] = "rectangle" + if not settings["width"]: + settings["width"] = obj.get("width") + if not settings["height"]: + settings["height"] = obj.get("height") + return settings diff --git a/screens/rendering/list_used_forge_characters.py b/screens/rendering/list_used_forge_characters.py index 88aed5e9..0b4394ab 100644 --- a/screens/rendering/list_used_forge_characters.py +++ b/screens/rendering/list_used_forge_characters.py @@ -9,7 +9,7 @@ def list_used_forge_characters(slug): réellement référencés par CE jeu — tous écrans, écrans-modèles compris (même patron que full_game_payload.py : un personnage peut vivre dans un écran-modèle d'élément réutilisable). Utilisé par - publish/build_package.py pour ne copier dans le .zip exporté que les + publish/build_scorm_package.py pour ne copier dans le .zip exporté que les dossiers static/characters/animals/// réellement utilisés par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque (voir le plan "Bibliothèque de sprites animaux CraftPix").""" diff --git a/screens/rendering/personnage_commands.py b/screens/rendering/personnage_commands.py new file mode 100644 index 00000000..1cbe44fe --- /dev/null +++ b/screens/rendering/personnage_commands.py @@ -0,0 +1,58 @@ +import json + +# ZQSD (convention AZERTY, comme le reste de l'éditeur) + E pour interagir +# — un objet de scène kind="personnage" se déplace et s'anime TOUT SEUL +# avec ces touches dès qu'il est posé sur la scène (voir +# static/js/play/personnage-controller.js), sans le moindre nœud de flow +# à poser. "axe" ("libre"/"horizontal"/"vertical") permet de bloquer un +# axe de déplacement (ex. un personnage qui ne se déplace que de gauche à +# droite). "vitesse" est en pixels par tick (50ms, voir +# personnage-controller.js — même cadence que touche_maintenue). +# "extra" : touches SUPPLÉMENTAIRES, chacune liée à une pose du personnage +# au choix (ex. "sauter" -> "jump") — au-delà des 4 touches de déplacement +# et de "interagir", qui restent les deux seules touches à usage FIXE. +DEFAULT_PERSONNAGE_COMMANDS = { + "haut": "z", "bas": "s", "gauche": "q", "droite": "d", + "interagir": "e", "axe": "libre", "vitesse": 4, "extra": [], +} + +# Nombre maximum de touches supplémentaires — large marge pour un vrai +# jeu (sauter, attaquer, courir...) sans laisser un panneau grossir sans +# fin par erreur de manipulation. +_MAX_EXTRA_COMMANDS = 20 + + +def sanitize_extra_commands(raw_extra): + if not isinstance(raw_extra, list): + return [] + cleaned = [] + for item in raw_extra[:_MAX_EXTRA_COMMANDS]: + if not isinstance(item, dict): + continue + touche = item.get("touche") + animation = item.get("animation") + if isinstance(touche, str) and touche and isinstance(animation, str) and animation: + cleaned.append({"touche": touche, "animation": animation}) + return cleaned + + +def resolve_personnage_commands(el): + """Décode attributes._personnage_commandes (voir + screens/scenes/set_scene_object_commands.py) en dict Python, jamais + None — un attribut absent/corrompu retombe sur les touches par défaut + ci-dessus (comportement inchangé pour tout personnage déjà posé avant + l'ajout de ce réglage).""" + raw = (el.get("attributes") or {}).get("_personnage_commandes") + commands = dict(DEFAULT_PERSONNAGE_COMMANDS) + commands["extra"] = [] # jamais l'alias de la liste DEFAULT (mutable partagée) + if raw: + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + parsed = None + if isinstance(parsed, dict): + for key in ("haut", "bas", "gauche", "droite", "interagir", "axe", "vitesse"): + if key in parsed: + commands[key] = parsed[key] + commands["extra"] = sanitize_extra_commands(parsed.get("extra")) + return commands diff --git a/screens/rendering/personnage_role.py b/screens/rendering/personnage_role.py new file mode 100644 index 00000000..8a6fb75a --- /dev/null +++ b/screens/rendering/personnage_role.py @@ -0,0 +1,20 @@ +# Rôle d'un personnage posé sur une scène 2D (voir +# screens/scenes/set_scene_object_role.py) : seul un personnage "joueur" +# est déplacé/animé automatiquement au clavier et suivi par la caméra +# (voir static/js/play/personnage-controller.js) — "ennemie"/"pnj" restent +# immobiles tant qu'aucune logique de flow ne les pilote (déplacement +# ennemi automatique/apparition, dialogue de PNJ... : hors scope de ce +# premier réglage, seulement l'étiquette qui permettra de les construire +# ensuite sans ambiguïté sur "quel objet est le joueur"). +PERSONNAGE_ROLES = ["joueur", "ennemie", "pnj"] +PERSONNAGE_ROLE_LABELS = {"joueur": "Joueur", "ennemie": "Ennemi", "pnj": "PNJ"} +DEFAULT_PERSONNAGE_ROLE = "joueur" + + +def resolve_personnage_role(el): + """Décode attributes._personnage_role — un attribut absent/corrompu + retombe sur "joueur" (comportement inchangé pour tout personnage posé + avant l'ajout de ce réglage : c'était déjà, de fait, le seul rôle qui + existait — tout personnage se déplaçait au clavier).""" + role = (el.get("attributes") or {}).get("_personnage_role") + return role if role in PERSONNAGE_ROLES else DEFAULT_PERSONNAGE_ROLE diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index a80dae60..335a4ab9 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -5,20 +5,27 @@ import db from .ensure_scene_schema import ensure_scene_schema _DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"} -_KINDS = {"personnage", "decor"} +_KINDS = {"personnage", "decor", "fond"} -def add_scene_object(slug, scene_id, kind="personnage", forge_character=None): +def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=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.""" + (attributes.src), pas d'animation. kind="fond" : même image fixe que + "decor" (render_scene_object.py ne les distingue pas au rendu), mais + posée en dessous de tout le reste (z_index très négatif, voir + static/style.css .sceneBackground) et à la taille RÉELLE de l'image + choisie (voir screens/labels/background_library.py) par défaut — une + image plus grande que la scène devient alors le "monde" que la caméra + suit en défilant (voir static/js/play/personnage-controller.js).""" ensure_scene_schema(slug) kind = kind if kind in _KINDS else "personnage" attributes = {} + width = height = None if kind == "personnage": data = dict(_DEFAULT_PERSONNAGE_DATA) if forge_character: @@ -27,15 +34,30 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None): if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY: data["forge_character"] = forge_character attributes["_personnage_data"] = json.dumps(data) + elif kind == "fond" and background_slug: + from ..labels.background_library import BACKGROUND_LIBRARY + background = BACKGROUND_LIBRARY.get(background_slug) + if background: + attributes["src"] = background["url"] + width, height = background["width"], background["height"] 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), - ) + if kind == "fond": + z_index = -1000 + else: + max_z = conn.execute( + "SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,) + ).fetchone()["m"] + z_index = (max_z or 0) + 1 + cols = ["scene_id", "kind", "attributes", "z_index"] + values = [scene_id, kind, json.dumps(attributes), z_index] + if width is not None: + # x=0/y=0 (jamais le défaut 100/100, voir ensure_scene_schema.py) : + # un fond commence au coin haut-gauche de la scène/du monde, pas + # décalé arbitrairement comme un objet ordinaire. + cols += ["width", "height", "x", "y"] + values += [width, height, 0, 0] + placeholders = ", ".join(["?"] * len(values)) + conn.execute(f"INSERT INTO _scene_objects ({', '.join(cols)}) VALUES ({placeholders})", values) object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] conn.commit() conn.close() diff --git a/screens/scenes/flow_palette.py b/screens/scenes/flow_palette.py index 778ac0d3..1cf47c0d 100644 --- a/screens/scenes/flow_palette.py +++ b/screens/scenes/flow_palette.py @@ -20,6 +20,8 @@ ACTION_TYPE_LABELS_2D = { "modifier_objet_scene": "Modifier un objet de scène", "modifier_variable": "Modifier une variable globale", "modifier_donnee": "Modifier une donnée d'un objet", + "modifier_score": "Modifier le score", + "definir_statut_partie": "Définir le statut de la partie", "jouer_animation_sprite": "Jouer une animation de sprites", "jouer_son": "Jouer un son", "declencher_evenement": "Déclencher un événement", diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index 5fa13253..1784cedc 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -46,5 +46,12 @@ def render_scene_object(obj): f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>') 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_collision.py b/screens/scenes/set_scene_object_collision.py new file mode 100644 index 00000000..6f995b29 --- /dev/null +++ b/screens/scenes/set_scene_object_collision.py @@ -0,0 +1,24 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_collision(slug, object_id, settings): + """Enregistre la boîte de collision (activée/désactivée, forme, + taille, décalage — voir screens/rendering/collision_settings.py) d'un + objet de scène QUELCONQUE (pas seulement "personnage" : un décor/fond + peut aussi servir d'obstacle à une condition de collision) — un + attribut JSON distinct de tous les autres réglages d'objet.""" + ensure_scene_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone() + if not row: + conn.close() + return + attributes = json.loads(row["attributes"] or "{}") + attributes["_collision"] = json.dumps(settings) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/screens/scenes/set_scene_object_collision_rules.py b/screens/scenes/set_scene_object_collision_rules.py new file mode 100644 index 00000000..2c2f08f1 --- /dev/null +++ b/screens/scenes/set_scene_object_collision_rules.py @@ -0,0 +1,23 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_collision_rules(slug, object_id, rules): + """Enregistre la liste de règles "déclencheur -> action" d'un objet de + scène (voir screens/rendering/collision_rules.py, "🧩 Collision" dans + templates/scene_edit.html) — un attribut JSON distinct de tous les + autres réglages d'objet.""" + ensure_scene_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone() + if not row: + conn.close() + return + attributes = json.loads(row["attributes"] or "{}") + attributes["_collision_rules"] = json.dumps(rules) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/screens/scenes/set_scene_object_commands.py b/screens/scenes/set_scene_object_commands.py new file mode 100644 index 00000000..0358e4e8 --- /dev/null +++ b/screens/scenes/set_scene_object_commands.py @@ -0,0 +1,26 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_commands(slug, object_id, commands): + """Enregistre les touches de déplacement/interaction et le blocage + d'axe d'un objet de scène kind="personnage" (voir + screens/rendering/personnage_commands.py pour les valeurs par défaut, + et static/js/play/personnage-controller.js pour leur usage au jeu) — + même patron que set_scene_object_personnage_data.py, mais un attribut + JSON DISTINCT (_personnage_commandes) pour ne jamais entrer en + conflit avec _personnage_data (choix du personnage/de ses animations).""" + 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_commandes"] = json.dumps(commands) + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/screens/scenes/set_scene_object_role.py b/screens/scenes/set_scene_object_role.py new file mode 100644 index 00000000..f67c8685 --- /dev/null +++ b/screens/scenes/set_scene_object_role.py @@ -0,0 +1,25 @@ +import json + +import db + +from .ensure_scene_schema import ensure_scene_schema + + +def set_scene_object_role(slug, object_id, role): + """Enregistre le rôle (joueur/ennemie/pnj, voir + screens/rendering/personnage_role.py) d'un objet de scène + kind="personnage" — un attribut DISTINCT de _personnage_data (choix du + personnage) et _personnage_commandes (touches), pour ne jamais les + écraser l'un l'autre à l'enregistrement (même raison que + set_scene_object_commands.py).""" + ensure_scene_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone() + if not row: + conn.close() + return + attributes = json.loads(row["attributes"] or "{}") + attributes["_personnage_role"] = role + conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id)) + conn.commit() + conn.close() diff --git a/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/scripts/backfill_onboarding_type.py b/scripts/backfill_onboarding_type.py new file mode 100644 index 00000000..00b29fe6 --- /dev/null +++ b/scripts/backfill_onboarding_type.py @@ -0,0 +1,41 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : pose la clé +_meta['onboarding_type'] sur tout projet existant qui ne l'a pas encore +(créé avant l'existence de l'onboarding guidé — voir routes/onboarding/ +onboarding_new.py et db/games/game_type_catalog.py). Pas strictement +nécessaire : get_onboarding_type() retombe déjà sur DEFAULT_ONBOARDING_TYPE +("custom") pour tout projet sans cette clé — ce script rend juste ce choix +EXPLICITE dans les données plutôt qu'implicite, sans rien changer au +comportement observable. + +Marque "custom" tout projet trouvé sans onboarding_type (comportement +neutre : tableau de bord complet garanti visible, comme aujourd'hui) — +aucune distinction "jeu_2d"/RPG à faire ici, cette notion n'existe plus au +niveau projet depuis la fusion des moteurs (écran par écran, voir +screens/screens_repo/ensure_schema.py) : un projet historiquement "jeu_2d" +reste "custom" comme n'importe quel autre, ses écrans gardent +individuellement leur kind="jeu_2d". + + python scripts/backfill_onboarding_type.py +""" +import os +import sys + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BASE_DIR) + +import db # noqa: E402 + + +def backfill(): + games = db.list_games() + updated = 0 + for g in games: + if db.get_onboarding_type_raw(g["slug"]) is None: + db.set_onboarding_type(g["slug"], "custom") + updated += 1 + print(f"{g['slug']!r} -> 'custom'") + print(f"{updated}/{len(games)} projet(s) mis à jour.") + + +if __name__ == "__main__": + backfill() diff --git a/scripts/generate_background_manifest.py b/scripts/generate_background_manifest.py new file mode 100644 index 00000000..5088f9fe --- /dev/null +++ b/scripts/generate_background_manifest.py @@ -0,0 +1,80 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : copie les +images de fond CraftPix depuis assets/background// (pack source, non +versionné dans static/) vers static/backgrounds//.png, et +écrit static/backgrounds/manifest.json (dimensions RÉELLES en pixels de +chaque image, lues dans l'en-tête PNG — nécessaires pour proposer une +taille par défaut cohérente à la pose de l'objet de scène "fond", voir +screens/scenes/add_scene_object.py). Réutilisé une fois par moi via +`python scripts/generate_background_manifest.py`, jamais importé par le +moteur. + +Accès réservé au rôle admin (voir screens/labels/background_library.py, +routes/scenes/scene_edit_view.py) : licence CraftPix, ne redistribue pas +ces images à des comptes "user" — même règle que la bibliothèque de +sprites animaux (screens/labels/animal_sprite_library.py).""" +import json +import os +import shutil +import struct + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SRC_ROOT = os.path.join(_BASE_DIR, "assets", "background") +_DST_ROOT = os.path.join(_BASE_DIR, "static", "backgrounds") + +# Nom de dossier source -> (slug interne court, libellé FR). Seule +# l'image COMBINÉE de premier niveau (game_background_N.png) est reprise +# pour l'instant — les calques séparés (layers/) permettraient un effet de +# parallaxe, hors scope de ce premier jet. +_PACKS = { + "craftpix-net-208967-flying-island-battle-backgrounds": ("flying_island", "Île volante"), +} + + +def _png_dimensions(path): + """Largeur/hauteur RÉELLES lues dans l'en-tête PNG (chunk IHDR, 8 + octets de signature + 4 octets de taille + 4 octets de type "IHDR" + avant les 2 x 4 octets de dimensions, big-endian) — évite une + dépendance à Pillow (absente de ce projet) pour un besoin aussi + ponctuel.""" + with open(path, "rb") as f: + header = f.read(24) + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise ValueError(f"pas un PNG valide : {path}") + width, height = struct.unpack(">II", header[16:24]) + return width, height + + +def main(): + manifest = {} + for pack_folder, (pack_slug, pack_label) in sorted(_PACKS.items()): + pack_dir = os.path.join(_SRC_ROOT, pack_folder, "PNG") + if not os.path.isdir(pack_dir): + print(f"SKIP (introuvable) : {pack_folder}") + continue + images = {} + for name in sorted(os.listdir(pack_dir)): + src_dir = os.path.join(pack_dir, name) + src_file = os.path.join(src_dir, f"{name}.png") + if not os.path.isfile(src_file): + continue + dst_dir = os.path.join(_DST_ROOT, pack_slug) + os.makedirs(dst_dir, exist_ok=True) + dst_file = os.path.join(dst_dir, f"{name}.png") + shutil.copy2(src_file, dst_file) + width, height = _png_dimensions(dst_file) + images[name] = {"width": width, "height": height} + if images: + manifest[pack_slug] = {"label": pack_label, "images": images} + print(f"{pack_slug} ({pack_label}) : {len(images)} images copiées") + else: + print(f"SKIP (aucune image) : {pack_folder}") + + os.makedirs(_DST_ROOT, exist_ok=True) + manifest_path = os.path.join(_DST_ROOT, "manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2, sort_keys=True) + print(f"\nManifest écrit : {manifest_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_flat_project_slugs.py b/scripts/migrate_flat_project_slugs.py new file mode 100644 index 00000000..36496bd1 --- /dev/null +++ b/scripts/migrate_flat_project_slugs.py @@ -0,0 +1,84 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : migre les +projets encore rangés à plat (projects//game.db, l'ancien schéma) +vers la structure par utilisateur (projects///, +voir db/games/project_slug.py et le plan "Structure de dossiers +propriétaire/projet") — db/game_dir.py sait déjà lire les deux formes, +cette migration n'est donc pas bloquante pour faire tourner le moteur, +mais range enfin les dossiers comme voulu et met à jour project_slug (voir +auth/set_project_slug.py) pour les comptes concernés. + +owner_folder = l'ancien slug plat lui-même : c'était déjà, historiquement, +slugify(email) pour le SEUL projet d'un compte "user" (voir l'ancienne +version de routes/auth/register_2fa.py, avant l'onboarding guidé) — donc +le bon dossier propriétaire, tel quel. project_part = slugify(nom du jeu +stocké en _meta), pour donner un vrai nom de dossier PROJET distinct. + +Mode simulation par défaut (n'écrit rien, affiche ce qui serait fait) — +`--apply` requis pour exécuter réellement : + python scripts/migrate_flat_project_slugs.py # aperçu + python scripts/migrate_flat_project_slugs.py --apply # exécute +""" +import os +import sys + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BASE_DIR) + +import db # noqa: E402 +import auth # noqa: E402 +from db.constants import PROJECTS_DIR # noqa: E402 +from db.games.project_slug import build_slug # noqa: E402 +from auth.connection import connect as connect_auth_db # noqa: E402 + + +def _flat_project_slugs(): + """Slugs plats trouvés directement sous projects/ (un dossier avec + game.db à la racine, pas dans un sous-dossier propriétaire).""" + if not os.path.isdir(PROJECTS_DIR): + return [] + found = [] + for entry in sorted(os.listdir(PROJECTS_DIR)): + entry_path = os.path.join(PROJECTS_DIR, entry) + if os.path.isfile(os.path.join(entry_path, "game.db")): + found.append(entry) + return found + + +def migrate(apply=False): + slugs = _flat_project_slugs() + if not slugs: + print("Aucun projet à plat trouvé — rien à migrer.") + return + conn = connect_auth_db() + for old_slug in slugs: + game_meta = db.game_meta(old_slug) + project_part = db.slugify(game_meta["name"]) or "jeu" + new_slug = build_slug(old_slug, project_part) + owning_users = [ + dict(r) for r in + conn.execute("SELECT id, email FROM _users WHERE project_slug = ?", (old_slug,)).fetchall() + ] + print(f"{old_slug!r} -> {new_slug!r}" + f" (comptes concernés : {[u['email'] for u in owning_users] or 'aucun'})") + if not apply: + continue + old_dir = os.path.join(PROJECTS_DIR, old_slug) + new_dir = os.path.join(PROJECTS_DIR, old_slug, project_part) + # Renommer EN PLACE (dossier propriétaire = ancien dossier projet, + # déplacé dans son propre sous-dossier) demanderait un dossier + # intermédiaire — plus sûr de passer par un nom temporaire. + tmp_dir = old_dir + ".migrating" + os.rename(old_dir, tmp_dir) + os.makedirs(os.path.dirname(new_dir), exist_ok=True) + os.rename(tmp_dir, new_dir) + for user in owning_users: + auth.set_project_slug(user["id"], new_slug) + conn.close() + if apply: + print(f"{len(slugs)} projet(s) migré(s).") + else: + print("Aperçu seulement — relance avec --apply pour exécuter.") + + +if __name__ == "__main__": + migrate(apply="--apply" in sys.argv) diff --git a/static/backgrounds/flying_island/game_background_1.png b/static/backgrounds/flying_island/game_background_1.png new file mode 100644 index 00000000..127dcc5d Binary files /dev/null and b/static/backgrounds/flying_island/game_background_1.png differ diff --git a/static/backgrounds/flying_island/game_background_2.png b/static/backgrounds/flying_island/game_background_2.png new file mode 100644 index 00000000..31a40a45 Binary files /dev/null and b/static/backgrounds/flying_island/game_background_2.png differ diff --git a/static/backgrounds/flying_island/game_background_3.png b/static/backgrounds/flying_island/game_background_3.png new file mode 100644 index 00000000..adda969c Binary files /dev/null and b/static/backgrounds/flying_island/game_background_3.png differ diff --git a/static/backgrounds/flying_island/game_background_4.png b/static/backgrounds/flying_island/game_background_4.png new file mode 100644 index 00000000..d1d8076c Binary files /dev/null and b/static/backgrounds/flying_island/game_background_4.png differ diff --git a/static/backgrounds/manifest.json b/static/backgrounds/manifest.json new file mode 100644 index 00000000..8300119e --- /dev/null +++ b/static/backgrounds/manifest.json @@ -0,0 +1,23 @@ +{ + "flying_island": { + "images": { + "game_background_1": { + "height": 2160, + "width": 3840 + }, + "game_background_2": { + "height": 2160, + "width": 3840 + }, + "game_background_3": { + "height": 2160, + "width": 3840 + }, + "game_background_4": { + "height": 2160, + "width": 3840 + } + }, + "label": "Île volante" + } +} \ No newline at end of file diff --git a/static/js/game_dashboard_simple.js b/static/js/game_dashboard_simple.js new file mode 100644 index 00000000..a053b6a8 --- /dev/null +++ b/static/js/game_dashboard_simple.js @@ -0,0 +1,15 @@ +// Dashboard simplifié (templates/game_dashboard_simple.html, voir +// routes/games/game_dashboard.py) — affiche/masque le petit formulaire +// "+ Ajouter un écran", même comportement que toggleDashCreate() du +// tableau de bord complet (static/js/screen_edit/tabs-and-blocks.js), +// repris ici pour ne pas dépendre de tout ce fichier (pensé pour l'éditeur +// complet, pas cette page). +function toggleDashCreate(tab) { + var bar = document.getElementById("dashCreate-" + tab); + if (!bar) return; + bar.style.display = bar.style.display === "none" ? "flex" : "none"; + if (bar.style.display === "flex") { + var firstInput = bar.querySelector("input, select"); + if (firstInput) firstInput.focus(); + } +} diff --git a/static/js/onboarding/onboarding_new.js b/static/js/onboarding/onboarding_new.js new file mode 100644 index 00000000..38b31703 --- /dev/null +++ b/static/js/onboarding/onboarding_new.js @@ -0,0 +1,65 @@ +// Onboarding guidé (templates/onboarding/onboarding_new.html) — interactions +// pures DOM, aucune logique métier ici (voir routes/onboarding/ +// onboarding_new.py pour la validation réelle, côté serveur). Deux +// "pages" (choix du type / nom du jeu) posées côte à côte dans +// #onboardingTrack, révélées par un défilement horizontal animé en CSS +// (voir .onboardingTrack--step2 dans static/style.css) plutôt qu'un vrai +// changement de page — pas de rechargement, pas d'aller-retour serveur +// tant que le formulaire n'est pas soumis pour de vrai. + +function forgeOnboardingGoToStep(step) { + var track = document.getElementById("onboardingTrack"); + if (track) track.classList.toggle("onboardingTrack--step2", step === 2); + if (step === 2) { + var nameField = document.getElementById("onboardingNameInput"); + // Après la fin de l'animation de défilement (voir la durée de + // transition posée sur .onboardingTrack) — focus immédiat plus tôt + // ferait sauter le scroll du champ à moitié visible. + if (nameField) setTimeout(function () { nameField.focus(); }, 500); + } +} + +function forgeOnboardingChooseType(card) { + document.querySelectorAll(".onboardingCard").forEach(function (c) { + c.classList.toggle("is-selected", c === card); + }); + var hiddenType = document.getElementById("onboardingTypeHidden"); + if (hiddenType) hiddenType.value = card.dataset.type || ""; + forgeOnboardingUpdateSubmitState(); + forgeOnboardingGoToStep(2); +} + +function forgeOnboardingUpdateSubmitState() { + var nameField = document.getElementById("onboardingNameInput"); + var hiddenType = document.getElementById("onboardingTypeHidden"); + var submitBtn = document.getElementById("onboardingSubmitBtn"); + if (!nameField || !hiddenType || !submitBtn) return; + submitBtn.disabled = !(hiddenType.value && nameField.value.trim()); +} + +function forgeOnboardingInit() { + // Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé + // par un +{% endblock %} diff --git a/templates/index.html b/templates/index.html index 662969d5..4ddc5ca5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,24 +11,17 @@

Créer un jeu

-
-
- - -
- -
- - -
-
-

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.

-
+ +
+

Choisis un type de jeu, nomme-le : Forge Engine crée le projet et t'envoie directement sur l'éditeur adapté.

+ +
diff --git a/templates/onboarding/onboarding_new.html b/templates/onboarding/onboarding_new.html new file mode 100644 index 00000000..cb147cec --- /dev/null +++ b/templates/onboarding/onboarding_new.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Forge Engine — Créer mon jeu{% endblock %} +{% block body_class %}objectEditBody{% endblock %} +{% block content_class %}content-wide onboardingPage{% endblock %} +{% block content %} +{% if error %}

⚠️ {{ error }}

{% endif %} + +
+ + +
+
+ +
+

Quel jeu veux-tu créer ?

+
+ {% for key, info in onboarding_types.items() %} + + {% endfor %} +
+
+ +
+ +
+ + + +
+
+ +
+
+
+ +{% endblock %} diff --git a/templates/play.html b/templates/play.html index c4c7913d..3c1875ee 100644 --- a/templates/play.html +++ b/templates/play.html @@ -4,12 +4,12 @@ {{ game.name }} — Jouer - - + + - - -