Merge pull request 'Dev' (#10) from dev into main
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -27,6 +27,11 @@ data/
|
|||||||
# qui les convertit en static/characters/animals/ (ça, c'est committé).
|
# qui les convertit en static/characters/animals/ (ça, c'est committé).
|
||||||
assets/characters/
|
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
|
# OS / éditeurs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
+23
-10
@@ -19,19 +19,19 @@ from .flask_app import app
|
|||||||
# Endpoints accessibles SANS être connecté — tout le reste exige une
|
# Endpoints accessibles SANS être connecté — tout le reste exige une
|
||||||
# session valide. "static" (CSS/JS/images) doit rester public : la page
|
# session valide. "static" (CSS/JS/images) doit rester public : la page
|
||||||
# de connexion elle-même en a besoin pour s'afficher.
|
# 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/<slug>, 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 = {
|
_PUBLIC_ENDPOINTS = {
|
||||||
"static", "login", "login_2fa", "register", "register_2fa", "logout",
|
"static", "login", "login_2fa", "register", "register_2fa", "logout",
|
||||||
"forgot_password", "reset_password",
|
"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 user["role"] != "admin":
|
||||||
if endpoint in ("index", "games_new") and user.get("project_slug"):
|
if endpoint in ("index", "games_new") and user.get("project_slug"):
|
||||||
return redirect(url_for("game_dashboard", slug=user["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
|
slug = request.view_args.get("slug") if request.view_args else None
|
||||||
if slug is not None and slug != user.get("project_slug"):
|
if slug is not None and slug != user.get("project_slug"):
|
||||||
abort(403)
|
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":
|
if endpoint == "game_delete":
|
||||||
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
|
# 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
|
# recréer un une fois supprimé (games_new le renvoie toujours
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Identité d'un visiteur anonyme de la route publique /jouer/<slug> (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/<slug>) 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
|
|
||||||
@@ -14,3 +14,13 @@ import screens
|
|||||||
def forbid_admin_only_character(forge_character):
|
def forbid_admin_only_character(forge_character):
|
||||||
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin":
|
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin":
|
||||||
abort(403)
|
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)
|
||||||
|
|||||||
+28
-4
@@ -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.
|
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 .slugify import slugify
|
||||||
from .table_name_for import table_name_for
|
from .table_name_for import table_name_for
|
||||||
from .game_dir import game_dir
|
from .game_dir import game_dir
|
||||||
@@ -29,13 +29,16 @@ from .connection import connect
|
|||||||
|
|
||||||
from .games.list_games import list_games
|
from .games.list_games import list_games
|
||||||
from .games.game_meta import game_meta
|
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.get_game_type import get_game_type, DEFAULT_GAME_TYPE
|
||||||
from .games.create_game import create_game
|
from .games.create_game import create_game
|
||||||
from .games.update_game_name import update_game_name
|
from .games.update_game_name import update_game_name
|
||||||
from .games.delete_game import delete_game
|
from .games.delete_game import delete_game
|
||||||
from .games.move_game import move_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.list_definitions import list_definitions
|
||||||
from .definitions.get_definition import get_definition
|
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 import delete_global_variable
|
||||||
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
|
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.list_custom_events import list_custom_events
|
||||||
from .custom_events.get_custom_event import get_custom_event
|
from .custom_events.get_custom_event import get_custom_event
|
||||||
from .custom_events.create_custom_event import create_custom_event
|
from .custom_events.create_custom_event import create_custom_event
|
||||||
from .custom_events.update_custom_event import update_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__ = [
|
__all__ = [
|
||||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
"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",
|
"slugify", "table_name_for", "game_dir", "db_path", "connect",
|
||||||
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
|
"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",
|
"list_definitions", "get_definition", "create_definition", "rename_definition",
|
||||||
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
|
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
|
||||||
"delete_definition",
|
"delete_definition",
|
||||||
@@ -85,4 +106,7 @@ __all__ = [
|
|||||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||||
"delete_global_variable_by_id",
|
"delete_global_variable_by_id",
|
||||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
"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",
|
||||||
]
|
]
|
||||||
|
|||||||
+17
-7
@@ -3,13 +3,9 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que
|
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que
|
||||||
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur
|
# auth/connection.py::FORGE_USERS_DB_PATH). Résolu ici, à l'IMPORT (pas
|
||||||
# joueur autonome (publish/player_app_template.py), qui pose cette
|
# par un appel de fonction) : la variable d'environnement doit donc déjà
|
||||||
# variable AVANT d'importer db/ pour pointer vers son propre dossier
|
# être posée avant le tout premier `import db`.
|
||||||
# "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`.
|
|
||||||
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
|
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
|
||||||
)
|
)
|
||||||
@@ -41,3 +37,17 @@ GLOBAL_VARIABLE_TYPES = {
|
|||||||
"objet": {"label": "Objet (JSON)"},
|
"objet": {"label": "Objet (JSON)"},
|
||||||
"tableau": {"label": "Tableau (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)
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .constants import PROJECTS_DIR
|
from .constants import PROJECTS_DIR
|
||||||
|
from .games.project_slug import split_slug
|
||||||
|
|
||||||
|
|
||||||
def game_dir(slug):
|
def game_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/<propriétaire>/<projet>/ — 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, slug)
|
||||||
|
return os.path.join(PROJECTS_DIR, owner_folder, project_part)
|
||||||
|
|||||||
+29
-17
@@ -3,30 +3,43 @@ import os
|
|||||||
from ..connection import connect
|
from ..connection import connect
|
||||||
from ..game_dir import game_dir
|
from ..game_dir import game_dir
|
||||||
from ..slugify import slugify
|
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
|
"""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).
|
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
|
owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py —
|
||||||
`name` — utilisé par l'inscription (auth/) pour que le dossier d'un
|
structure de dossiers par utilisateur), typiquement
|
||||||
compte porte son adresse email (slugifiée), indépendamment du nom
|
slugify(email_du_compte) — le slug final composé
|
||||||
donné à son jeu. Toujours rendu unique de la même façon (suffixe
|
"<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué
|
||||||
-2/-3...) si, par un hasard extrême, le dossier existe déjà.
|
projects/<owner_folder>/<project_part>/. Omis (None) : repli sur
|
||||||
|
l'ancien comportement plat (slug à un seul segment, projects/<slug>/)
|
||||||
|
— 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"
|
project_slug_override : impose le nom du dossier PROJET au lieu de le
|
||||||
(éditeur de scène — voir get_game_type.py) — choisi une fois pour
|
dériver de `name` — utilisé par l'inscription (auth/, voir
|
||||||
toutes à la création, gate quel éditeur/quel rendu jouable ce jeu
|
routes/onboarding/) pour que le tout premier projet d'un compte porte
|
||||||
utilise (voir routes/screens/screen_edit.py)."""
|
un nom de dossier prévisible. Le suffixe d'unicité (-2/-3...) ne
|
||||||
slug = slugify(slug_override) if slug_override else slugify(name)
|
compare que parmi les projets déjà présents dans CE `owner_folder`
|
||||||
base = slug
|
(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
|
n = 2
|
||||||
while os.path.isdir(game_dir(base)):
|
while os.path.isdir(game_dir(build_slug(owner_folder, base) if owner_folder else base)):
|
||||||
base = f"{slug}-{n}"
|
base = f"{project_part}-{n}"
|
||||||
n += 1
|
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)
|
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 ('name', ?)", (name,))
|
||||||
conn.execute("INSERT INTO _meta (key, value) VALUES ('game_type', ?)", (game_type,))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return slug
|
return slug
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
from ..constants import PROJECTS_DIR
|
||||||
from ..game_dir import game_dir
|
from ..game_dir import game_dir
|
||||||
|
from .project_slug import split_slug
|
||||||
|
|
||||||
|
|
||||||
def delete_game(slug):
|
def delete_game(slug):
|
||||||
shutil.rmtree(game_dir(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)
|
||||||
|
|||||||
+8
-11
@@ -1,6 +1,5 @@
|
|||||||
from ..connection import connect
|
from ..connection import connect
|
||||||
from .is_public_played import is_public_played
|
from .game_type_catalog import get_onboarding_type
|
||||||
from .get_game_type import get_game_type
|
|
||||||
|
|
||||||
|
|
||||||
def game_meta(slug):
|
def game_meta(slug):
|
||||||
@@ -9,13 +8,11 @@ def game_meta(slug):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return {
|
return {
|
||||||
"slug": slug, "name": row["value"] if row else slug,
|
"slug": slug, "name": row["value"] if row else slug,
|
||||||
# État par joueur (Phase 1) : disponible partout où `game` est
|
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
|
||||||
# dans le contexte du template (base.html, la bascule "Publier en
|
# templates/base.html pour cacher le lien "Tableau de bord" à un
|
||||||
# ligne" en a besoin pour afficher son état actuel), sans avoir à
|
# compte "restreint" (quiz/embranchement/rpg).
|
||||||
# appeler is_public_played(slug) séparément dans chaque route.
|
"onboarding_type": get_onboarding_type(slug),
|
||||||
"is_public_played": is_public_played(slug),
|
# Pas de "game_type" ici : depuis la fusion des moteurs (voir
|
||||||
# Type de jeu (fondations multi-éditeurs) : "document" (éditeur
|
# screens/screens_repo/ensure_schema.py), "document"/"jeu_2d" est
|
||||||
# générique) ou "jeu_2d" (éditeur de scène) — voir
|
# une propriété PAR ÉCRAN (screen["kind"]), plus du projet.
|
||||||
# get_game_type.py et routes/screens/screen_edit.py.
|
|
||||||
"game_type": get_game_type(slug),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
@@ -8,9 +8,10 @@ def get_game_type(slug):
|
|||||||
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
|
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
|
||||||
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
|
é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).
|
voir screens/scenes/, écrans = objets pixel-positionnés en couches).
|
||||||
Réutilise _meta comme is_public_played — aucune nouvelle table. Un jeu
|
Stocké dans _meta (une table générique clé/valeur, déjà utilisée pour
|
||||||
créé avant l'existence de cette clé (absente de _meta) retombe sur
|
d'autres réglages de projet) — aucune nouvelle table. Un jeu créé avant
|
||||||
"document", son comportement d'avant, inchangé."""
|
l'existence de cette clé (absente de _meta) retombe sur "document",
|
||||||
|
son comportement d'avant, inchangé."""
|
||||||
conn = connect(slug)
|
conn = connect(slug)
|
||||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone()
|
row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
from ..connection import connect
|
|
||||||
|
|
||||||
|
|
||||||
def is_public_played(slug):
|
|
||||||
"""Un jeu n'est jouable sur la route publique /jouer/<slug> (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")
|
|
||||||
+22
-5
@@ -3,18 +3,35 @@ import os
|
|||||||
from ..constants import PROJECTS_DIR
|
from ..constants import PROJECTS_DIR
|
||||||
from ..connection import connect
|
from ..connection import connect
|
||||||
from ..db_path import db_path
|
from ..db_path import db_path
|
||||||
|
from .project_slug import build_slug
|
||||||
|
|
||||||
|
|
||||||
def list_games():
|
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/<propriétaire>/<projet>/game.db,
|
||||||
|
deux niveaux — plus le repli sur l'ancien rangement plat
|
||||||
|
projects/<slug>/game.db pour tout slug pas encore migré (voir
|
||||||
|
scripts/migrate_flat_project_slugs.py)."""
|
||||||
games = []
|
games = []
|
||||||
if not os.path.isdir(PROJECTS_DIR):
|
if not os.path.isdir(PROJECTS_DIR):
|
||||||
return games
|
return games
|
||||||
for slug in sorted(os.listdir(PROJECTS_DIR)):
|
for entry in sorted(os.listdir(PROJECTS_DIR)):
|
||||||
path = db_path(slug)
|
entry_path = os.path.join(PROJECTS_DIR, entry)
|
||||||
if os.path.isfile(path):
|
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)
|
conn = connect(slug)
|
||||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
|
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
games.append({"slug": slug, "name": row["value"] if row else slug})
|
games.append({"slug": slug, "name": row["value"] if row else slug})
|
||||||
return games
|
|
||||||
|
|||||||
+33
-13
@@ -1,23 +1,43 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
from ..constants import PROJECTS_DIR
|
||||||
from ..game_dir import game_dir
|
from ..game_dir import game_dir
|
||||||
|
from .project_slug import build_slug, split_slug
|
||||||
|
|
||||||
|
|
||||||
def move_game(old_slug, new_slug):
|
def move_game(old_slug, new_owner_folder):
|
||||||
"""Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un
|
"""Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un
|
||||||
utilisateur change son adresse email (voir routes/auth/profile.py,
|
utilisateur change son adresse email (voir routes/auth/profile.py,
|
||||||
project_slug = slugify(email) pour un compte "user") : le dossier
|
owner_folder = slugify(email) pour un compte "user", voir
|
||||||
physique du jeu doit suivre. Rend le nouveau slug unique de la même
|
db/games/project_slug.py) : le dossier physique doit suivre. Renomme
|
||||||
façon que create_game() si, par un hasard extrême, il correspond déjà
|
le dossier propriétaire ENTIER en un coup (déplace tous les projets de
|
||||||
à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé),
|
ce compte ensemble — prêt pour un futur multi-projet, même si un seul
|
||||||
à enregistrer comme nouveau project_slug."""
|
aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire
|
||||||
if old_slug == new_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 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
|
return old_slug
|
||||||
base = new_slug
|
base = new_owner_folder
|
||||||
n = 2
|
n = 2
|
||||||
while os.path.isdir(game_dir(base)):
|
while os.path.isdir(game_dir(base)):
|
||||||
base = f"{new_slug}-{n}"
|
base = f"{new_owner_folder}-{n}"
|
||||||
n += 1
|
n += 1
|
||||||
new_slug = base
|
os.rename(game_dir(old_slug), game_dir(base))
|
||||||
os.rename(game_dir(old_slug), game_dir(new_slug))
|
return base
|
||||||
return new_slug
|
|
||||||
|
if old_owner_folder == new_owner_folder:
|
||||||
|
return old_slug
|
||||||
|
base = new_owner_folder
|
||||||
|
n = 2
|
||||||
|
while os.path.isdir(os.path.join(PROJECTS_DIR, base)):
|
||||||
|
base = f"{new_owner_folder}-{n}"
|
||||||
|
n += 1
|
||||||
|
os.rename(os.path.join(PROJECTS_DIR, old_owner_folder), os.path.join(PROJECTS_DIR, base))
|
||||||
|
return build_slug(base, project_part)
|
||||||
|
|||||||
@@ -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/<propriétaire>/<projet>/), sans jamais toucher aux ~40 fichiers
|
||||||
|
de routes qui déclarent <slug> 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
|
||||||
@@ -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()
|
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)."""
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
|
"""publish — empaquette un jeu en export Web/SCORM (voir
|
||||||
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
|
build_scorm_package.py) : le bouton "Exporter (Web/SCORM)" de la barre
|
||||||
de navigation d'un jeu (templates/base.html)."""
|
de navigation d'un jeu (templates/base.html)."""
|
||||||
|
|||||||
@@ -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 (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
|
|
||||||
`animals/<animal>/<NN>/` (CraftPix, ~1,5-2 Go pour les 210 variantes)
|
|
||||||
— celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN)
|
|
||||||
correspondant aux personnages `animal-<animal>-<NN>` 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-<animal>-<NN>" (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"]
|
|
||||||
@@ -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 (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
|
||||||
|
`animals/<animal>/<NN>/` (CraftPix, ~1,5-2 Go pour les 210 variantes)
|
||||||
|
— celui-ci copié SÉLECTIVEMENT, seulement les dossiers (animal, NN)
|
||||||
|
correspondant aux personnages `animal-<animal>-<NN>` 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-<animal>-<NN>" (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/<slug>/uploads/<fichier>, 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/<slug>/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/<slug>.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/<slug>/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"]
|
||||||
@@ -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/<slug>/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/<slug>/runtime-payload")
|
|
||||||
def runtime_payload(slug):
|
|
||||||
return jsonify(screens.full_game_payload(SLUG))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/flow/nodes/<int:node_id>/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/<slug>/flow/nodes/<int:node_id>/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/<slug>/uploads/<path:filename>")
|
|
||||||
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)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
python-embed\python.exe player_app.py
|
|
||||||
pause
|
|
||||||
@@ -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 = """<?xml version="1.0" standalone="no" ?>
|
||||||
|
<manifest identifier="FORGE_{identifier}" version="1"
|
||||||
|
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
|
||||||
|
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd
|
||||||
|
http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
|
||||||
|
<metadata>
|
||||||
|
<schema>ADL SCORM</schema>
|
||||||
|
<schemaversion>1.2</schemaversion>
|
||||||
|
</metadata>
|
||||||
|
<organizations default="FORGE_ORG_{identifier}">
|
||||||
|
<organization identifier="FORGE_ORG_{identifier}">
|
||||||
|
<title>{title}</title>
|
||||||
|
<item identifier="FORGE_ITEM_{identifier}" identifierref="FORGE_RES_{identifier}">
|
||||||
|
<title>{title}</title>
|
||||||
|
</item>
|
||||||
|
</organization>
|
||||||
|
</organizations>
|
||||||
|
<resources>
|
||||||
|
<resource identifier="FORGE_RES_{identifier}" type="webcontent" adlcp:scormtype="sco" href="index.html">
|
||||||
|
<file href="index.html"/>
|
||||||
|
{file_entries}
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</manifest>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
' <file href="{}"/>'.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,
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -52,5 +52,10 @@ def login_2fa():
|
|||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
return redirect(url_for("index"))
|
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 redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||||
return render_template("auth/login_2fa.html", error=error)
|
return render_template("auth/login_2fa.html", error=error)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
|
|
||||||
from flask import g, redirect, render_template, request, session, url_for
|
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
|
# plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses
|
||||||
# projets restent intacts.
|
# projets restent intacts.
|
||||||
if user["role"] != "admin" and user.get("project_slug"):
|
if user["role"] != "admin" and user.get("project_slug"):
|
||||||
game_dir = db.game_dir(user["project_slug"])
|
if os.path.isdir(db.game_dir(user["project_slug"])):
|
||||||
if os.path.isdir(game_dir):
|
# db.delete_game (voir db/games/project_slug.py — structure de
|
||||||
shutil.rmtree(game_dir)
|
# 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"])
|
auth.delete_user(user["id"])
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import redirect, render_template, request, session, url_for
|
from flask import redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -28,16 +27,6 @@ def register_2fa():
|
|||||||
else:
|
else:
|
||||||
auth.reset_failed_attempts(user_id)
|
auth.reset_failed_attempts(user_id)
|
||||||
auth.confirm_totp(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.pop("pending_register_user_id", None)
|
||||||
session["user_id"] = user_id
|
session["user_id"] = user_id
|
||||||
# Générés ici, une seule fois, à l'instant même où la 2FA
|
# 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)
|
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id)
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
return redirect(url_for("index"))
|
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"])
|
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from . import (
|
|||||||
flow_edge_delete,
|
flow_edge_delete,
|
||||||
flow_node_run_data,
|
flow_node_run_data,
|
||||||
flow_node_run_variable,
|
flow_node_run_variable,
|
||||||
|
flow_node_run_score,
|
||||||
|
flow_node_run_status,
|
||||||
flow_node_run_add_row,
|
flow_node_run_add_row,
|
||||||
runtime_payload,
|
runtime_payload,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/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})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/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})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -13,8 +13,42 @@ def game_dashboard(slug):
|
|||||||
accès direct à chaque élément — la barre de navigation du jeu (voir
|
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
|
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
|
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)
|
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)
|
definitions = db.list_definitions(slug)
|
||||||
# Détail complet (avec ses champs) de chaque objet, pour le panneau
|
# 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
|
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scoring/<player_id>")
|
||||||
|
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/<slug>, 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))
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from flask import request, redirect, url_for
|
|
||||||
|
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/game/<slug>/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/<slug> (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))
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import request, redirect, url_for
|
from flask import g, request, redirect, url_for
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
|
||||||
@@ -10,8 +10,12 @@ def games_new():
|
|||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
game_type = request.form.get("game_type", db.DEFAULT_GAME_TYPE)
|
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
|
||||||
if game_type not in ("document", "jeu_2d"):
|
# jeu n'a plus de type propre — aucun écran créé d'office ici, comme
|
||||||
game_type = db.DEFAULT_GAME_TYPE
|
# avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en
|
||||||
slug = db.create_game(name, game_type=game_type)
|
# 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))
|
return redirect(url_for("game_dashboard", slug=slug))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import onboarding_new
|
||||||
@@ -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)
|
||||||
@@ -18,8 +18,11 @@ def game_play(slug):
|
|||||||
payload = screens.full_game_payload(slug)
|
payload = screens.full_game_payload(slug)
|
||||||
return render_template(
|
return render_template(
|
||||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
"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),
|
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_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_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),
|
flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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/<slug>/flow/nodes/<int:node_id>/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
|
|
||||||
@@ -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/<slug>/flow/nodes/<int:node_id>/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
|
|
||||||
@@ -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/<slug>/flow/nodes/<int:node_id>/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
|
|
||||||
@@ -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/<slug>")
|
|
||||||
def game_play_public(slug):
|
|
||||||
"""Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) —
|
|
||||||
distincte de /game/<slug>/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
|
|
||||||
@@ -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/<slug>/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
|
|
||||||
@@ -1 +1 @@
|
|||||||
from . import publish_game
|
from . import export_scorm
|
||||||
|
|||||||
@@ -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/<slug>/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/<slug>/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")
|
||||||
@@ -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/<slug>/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/<slug>/... (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")
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/quests/<int:quest_id>/delete", methods=["POST"])
|
||||||
|
def quest_delete(slug, quest_id):
|
||||||
|
db.delete_quest(slug, quest_id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/quests/<int:quest_id>/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})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/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})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/quests/<int:quest_id>", 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})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/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)})
|
||||||
@@ -3,4 +3,8 @@ from . import (
|
|||||||
scene_object_geometry,
|
scene_object_geometry,
|
||||||
scene_object_delete,
|
scene_object_delete,
|
||||||
scene_object_personnage_data,
|
scene_object_personnage_data,
|
||||||
|
scene_object_commands,
|
||||||
|
scene_object_role,
|
||||||
|
scene_object_collision,
|
||||||
|
scene_object_collision_rules,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import screens
|
|||||||
|
|
||||||
def render_scene_edit(slug, screen_id, game):
|
def render_scene_edit(slug, screen_id, game):
|
||||||
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
|
"""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
|
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
|
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 le plan "Fondations
|
de flow/animation/variables/événements (voir screens/screens_repo/
|
||||||
d'une plateforme multi-éditeurs") — seuls les objets de scène et la
|
ensure_schema.py — le type d'écran est une propriété PAR ÉCRAN) —
|
||||||
palette de nœuds proposée diffèrent de l'éditeur document."""
|
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)
|
screen = screens.get_screen(slug, screen_id)
|
||||||
if not screen:
|
if not screen:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -22,18 +23,49 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
o["rendered_html"] = screens.render_scene_object(o)
|
o["rendered_html"] = screens.render_scene_object(o)
|
||||||
selected_id = request.args.get("selected", type=int)
|
selected_id = request.args.get("selected", type=int)
|
||||||
personnage_data = None
|
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
|
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":
|
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_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 = {
|
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
|
for o in objects
|
||||||
}
|
}
|
||||||
element_animations_map = {
|
element_animations_map = {
|
||||||
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
|
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_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||||
flow_edges = screens.list_flow_edges(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").
|
# "Bibliothèque de sprites animaux CraftPix").
|
||||||
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
|
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)
|
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(
|
return render_template(
|
||||||
"scene_edit.html",
|
"scene_edit.html",
|
||||||
game=game, screen=screen, all_screens=all_screens,
|
game=game, screen=screen, all_screens=all_screens,
|
||||||
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
|
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=sprite_library, sprite_library_families=sprite_library_families,
|
||||||
sprite_library_json=json.dumps(sprite_library),
|
sprite_library_json=json.dumps(sprite_library),
|
||||||
|
background_library=background_library,
|
||||||
element_animations_map_json=json.dumps(element_animations_map),
|
element_animations_map_json=json.dumps(element_animations_map),
|
||||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
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)),
|
element_action_property_labels_json=json.dumps(dict(screens.OBJECT_ACTION_PROPERTIES)),
|
||||||
condition_operators=screens.CONDITION_OPERATORS,
|
condition_operators=screens.CONDITION_OPERATORS,
|
||||||
data_operations=screens.DATA_OPERATIONS, data_operation_labels=screens.DATA_OPERATION_LABELS,
|
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),
|
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
|
||||||
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
|
||||||
action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS_2D),
|
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=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_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)}),
|
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)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from flask import request, redirect, url_for
|
|||||||
import screens
|
import screens
|
||||||
|
|
||||||
from core.flask_app import app
|
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/<slug>/screens/<int:screen_id>/scene-objects/add", methods=["POST"])
|
@app.route("/game/<slug>/screens/<int:screen_id>/scene-objects/add", methods=["POST"])
|
||||||
@@ -13,9 +13,14 @@ def scene_object_add(slug, screen_id):
|
|||||||
add_scene_object.py)."""
|
add_scene_object.py)."""
|
||||||
kind = request.form.get("kind", "personnage")
|
kind = request.form.get("kind", "personnage")
|
||||||
forge_character = request.form.get("forge_character")
|
forge_character = request.form.get("forge_character")
|
||||||
|
background_slug = request.form.get("background_slug")
|
||||||
if forge_character:
|
if forge_character:
|
||||||
forbid_admin_only_character(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/<slug>/screens/<id>/edit,
|
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
|
||||||
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
|
||||||
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
|
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/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)})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/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})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/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})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/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})
|
||||||
@@ -12,15 +12,17 @@ from ..scenes.scene_edit_view import render_scene_edit
|
|||||||
@app.route("/game/<slug>/screens/<int:screen_id>/edit")
|
@app.route("/game/<slug>/screens/<int:screen_id>/edit")
|
||||||
def screen_edit(slug, screen_id):
|
def screen_edit(slug, screen_id):
|
||||||
game = db.game_meta(slug)
|
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)
|
screen = screens.get_screen(slug, screen_id)
|
||||||
if not screen:
|
if not screen:
|
||||||
abort(404)
|
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)
|
elements = screens.list_elements(slug, screen_id)
|
||||||
all_screens = screens.list_screens(slug)
|
all_screens = screens.list_screens(slug)
|
||||||
element_types = screens.list_element_types(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),
|
element_animations_map_json=json.dumps(element_animations_map),
|
||||||
data_operations=screens.DATA_OPERATIONS,
|
data_operations=screens.DATA_OPERATIONS,
|
||||||
data_operation_labels=screens.DATA_OPERATION_LABELS,
|
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),
|
definitions=definitions, definitions_json=json.dumps(definitions_data),
|
||||||
selected_id=selected_id, selected_controls=selected_controls,
|
selected_id=selected_id, selected_controls=selected_controls,
|
||||||
flow_nodes=flow_nodes, flow_edges=flow_edges,
|
flow_nodes=flow_nodes, flow_edges=flow_edges,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from flask import request, redirect, url_for
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
@@ -8,7 +9,19 @@ from core.flask_app import app
|
|||||||
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
||||||
def screens_new(slug):
|
def screens_new(slug):
|
||||||
name = request.form.get("name", "").strip()
|
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:
|
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("screen_edit", slug=slug, screen_id=screen_id))
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
|
|||||||
SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
|
SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
|
||||||
ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY)
|
ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY)
|
||||||
from .labels.sprite_gallery import sprite_gallery_families
|
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 .rendering.list_used_forge_characters import list_used_forge_characters
|
||||||
|
|
||||||
from .flow.constants import (
|
from .flow.constants import (
|
||||||
@@ -76,6 +81,13 @@ from .flow.constants import (
|
|||||||
from .rendering.render_element_html import render_element_html
|
from .rendering.render_element_html import render_element_html
|
||||||
from .rendering.trigger_for import _trigger_for
|
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_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.ensure_schema import ensure_schema
|
||||||
from .screens_repo.list_screens import list_screens
|
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.update_scene_object_geometry import update_scene_object_geometry
|
||||||
from .scenes.delete_scene_object import delete_scene_object
|
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_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.render_scene_object import render_scene_object
|
||||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||||
from .elements.element_label import element_label
|
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_data_action import apply_data_action
|
||||||
from .data_actions.apply_variable_action import apply_variable_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 .data_actions.apply_add_row_action import apply_add_row_action
|
||||||
|
|
||||||
from .animations.ensure_animation_schema import ensure_animation_schema
|
from .animations.ensure_animation_schema import ensure_animation_schema
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -5,6 +5,8 @@ ACTION_TYPE_LABELS = {
|
|||||||
"modifier_element": "Modifier un élément",
|
"modifier_element": "Modifier un élément",
|
||||||
"modifier_donnee": "Modifier une donnée d'un objet",
|
"modifier_donnee": "Modifier une donnée d'un objet",
|
||||||
"modifier_variable": "Modifier une variable globale",
|
"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",
|
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
|
||||||
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
|
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
|
||||||
"attendre": "Attendre quelques secondes avant de continuer",
|
"attendre": "Attendre quelques secondes avant de continuer",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def _load_manifest():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Poste de dev sans les assets animaux copiés (script pas encore
|
# Poste de dev sans les assets animaux copiés (script pas encore
|
||||||
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun
|
# 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.
|
# sélective) : aucun personnage animal disponible, pas une erreur.
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -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>/
|
||||||
|
# (pack source, non versionné) vers static/backgrounds/<pack_slug>/
|
||||||
|
# <image>.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()
|
||||||
@@ -6,42 +6,71 @@ from ..flow.list_flow_nodes import list_flow_nodes
|
|||||||
from ..flow.list_flow_edges import list_flow_edges
|
from ..flow.list_flow_edges import list_flow_edges
|
||||||
from ..animations.list_animation_clips import list_animation_clips
|
from ..animations.list_animation_clips import list_animation_clips
|
||||||
from ..element_types.list_element_types import list_element_types
|
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_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.list_scene_objects import list_scene_objects
|
||||||
from ..scenes.render_scene_object import render_scene_object
|
from ..scenes.render_scene_object import render_scene_object
|
||||||
|
|
||||||
|
|
||||||
def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
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
|
"""Toutes les données nécessaires au runtime JS de la page de jeu jouable
|
||||||
(/game/<slug>/play, ou /jouer/<slug> — voir routes/public_play/) : chaque
|
(/game/<slug>/play, ou le paquet Web/SCORM exporté — voir
|
||||||
écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de
|
publish/build_scorm_package.py) : chaque écran avec ses éléments (HTML
|
||||||
données, lus en direct), le graphe de logique (nœuds + fils) de chaque
|
déjà généré, y compris les Répéteurs de données, lus en direct), le
|
||||||
écran, et un instantané des données du jeu (avec le type de chaque
|
graphe de logique (nœuds + fils) de chaque écran, et un instantané des
|
||||||
champ, nécessaire pour évaluer une condition correctement) — un seul
|
données du jeu (avec le type de chaque champ, nécessaire pour évaluer
|
||||||
aller-retour serveur, ensuite tout se joue côté client (aucune
|
une condition correctement) — un seul aller-retour serveur, ensuite
|
||||||
navigation ne recharge la page, sauf pour appliquer une action "modifier
|
tout se joue côté client (aucune navigation ne recharge la page, sauf
|
||||||
une donnée", qui doit passer par le serveur).
|
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
|
player_id (état par joueur, Phase 1) : toujours PLAYER_SHARED
|
||||||
créateur (comportement inchangé — une seule partie partagée pendant la
|
aujourd'hui (une seule partie partagée, pour la conception comme pour
|
||||||
conception), l'identifiant réel du visiteur pour une partie publique
|
un paquet exporté) — le paramètre reste générique pour tout objet/
|
||||||
(voir core/player_identity.py) — chaque objet/variable per_player
|
variable per_player (voir db/global_vars/, db/rows/), qui renvoie
|
||||||
renvoie alors la valeur propre à CE joueur."""
|
alors la valeur propre à CE joueur."""
|
||||||
# Fondations multi-éditeurs — un jeu "jeu_2d" (voir db/games/
|
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
|
||||||
# get_game_type.py) construit "elements" depuis _scene_objects
|
# écran "jeu_2d" construit "elements" depuis _scene_objects (objets de
|
||||||
# (objets de scène en pixels) plutôt que _screen_elements (DOM en %) ;
|
# 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)
|
# tout le reste de cette fonction (flows/animations/variables/data)
|
||||||
# est déjà générique et inchangé pour les deux types de jeu.
|
# est déjà générique et inchangé pour les deux types d'écran.
|
||||||
is_scene_game = db.get_game_type(slug) == "jeu_2d"
|
|
||||||
screens_ = list_screens(slug)
|
screens_ = list_screens(slug)
|
||||||
payload_screens = []
|
payload_screens = []
|
||||||
flows = {}
|
flows = {}
|
||||||
animations = {}
|
animations = {}
|
||||||
for s in screens_:
|
for s in screens_:
|
||||||
if is_scene_game:
|
if s["kind"] == "jeu_2d":
|
||||||
objects = list_scene_objects(slug, s["id"])
|
objects = list_scene_objects(slug, s["id"])
|
||||||
for o in objects:
|
for o in objects:
|
||||||
o["rendered_html"] = render_scene_object(o)
|
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})
|
payload_screens.append({**s, "elements": objects})
|
||||||
else:
|
else:
|
||||||
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
|
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"]),
|
"edges": list_flow_edges(slug, s["id"]),
|
||||||
}
|
}
|
||||||
animations[str(s["id"])] = list_animation_clips(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"]):
|
for o in list_scene_objects(slug, s["id"]):
|
||||||
if o["kind"] == "personnage":
|
if o["kind"] == "personnage":
|
||||||
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
|
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:
|
for d in definitions:
|
||||||
full = db.get_definition(slug, d["id"])
|
full = db.get_definition(slug, d["id"])
|
||||||
rows = db.list_rows(slug, full, player_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 "<champ>_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"])] = [
|
data[str(d["id"])] = [
|
||||||
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
|
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
|
||||||
for r in rows
|
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
|
# 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
|
# 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.
|
# modèle est utilisé ailleurs.
|
||||||
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
|
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
|
# Instantané des variables globales, pour qu'un nœud Condition puisse en
|
||||||
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
||||||
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
|
# {{$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)
|
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 {
|
return {
|
||||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||||
"variables": variables, "personnage_animations": personnage_animations,
|
"variables": variables, "personnage_animations": personnage_animations,
|
||||||
|
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
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
|
(même patron que full_game_payload.py : un personnage peut vivre dans
|
||||||
un écran-modèle d'élément réutilisable). Utilisé par
|
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/<animal>/<NN>/ réellement utilisés
|
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
|
||||||
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
|
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
|
||||||
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
|
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -5,20 +5,27 @@ import db
|
|||||||
from .ensure_scene_schema import ensure_scene_schema
|
from .ensure_scene_schema import ensure_scene_schema
|
||||||
|
|
||||||
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
_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).
|
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
|
||||||
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
|
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
|
||||||
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
|
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
|
||||||
document (screens/rendering/personnage_data.py) — un objet de scène
|
document (screens/rendering/personnage_data.py) — un objet de scène
|
||||||
"personnage" partage donc directement la bibliothèque Forge et le
|
"personnage" partage donc directement la bibliothèque Forge et le
|
||||||
système d'animation déjà écrits. kind="decor" : une simple image fixe
|
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)
|
ensure_scene_schema(slug)
|
||||||
kind = kind if kind in _KINDS else "personnage"
|
kind = kind if kind in _KINDS else "personnage"
|
||||||
attributes = {}
|
attributes = {}
|
||||||
|
width = height = None
|
||||||
if kind == "personnage":
|
if kind == "personnage":
|
||||||
data = dict(_DEFAULT_PERSONNAGE_DATA)
|
data = dict(_DEFAULT_PERSONNAGE_DATA)
|
||||||
if forge_character:
|
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:
|
if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY:
|
||||||
data["forge_character"] = forge_character
|
data["forge_character"] = forge_character
|
||||||
attributes["_personnage_data"] = json.dumps(data)
|
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)
|
conn = db.connect(slug)
|
||||||
|
if kind == "fond":
|
||||||
|
z_index = -1000
|
||||||
|
else:
|
||||||
max_z = conn.execute(
|
max_z = conn.execute(
|
||||||
"SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,)
|
"SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,)
|
||||||
).fetchone()["m"]
|
).fetchone()["m"]
|
||||||
conn.execute(
|
z_index = (max_z or 0) + 1
|
||||||
"""INSERT INTO _scene_objects (scene_id, kind, attributes, z_index)
|
cols = ["scene_id", "kind", "attributes", "z_index"]
|
||||||
VALUES (?, ?, ?, ?)""",
|
values = [scene_id, kind, json.dumps(attributes), z_index]
|
||||||
(scene_id, kind, json.dumps(attributes), (max_z or 0) + 1),
|
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"]
|
object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ ACTION_TYPE_LABELS_2D = {
|
|||||||
"modifier_objet_scene": "Modifier un objet de scène",
|
"modifier_objet_scene": "Modifier un objet de scène",
|
||||||
"modifier_variable": "Modifier une variable globale",
|
"modifier_variable": "Modifier une variable globale",
|
||||||
"modifier_donnee": "Modifier une donnée d'un objet",
|
"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_animation_sprite": "Jouer une animation de sprites",
|
||||||
"jouer_son": "Jouer un son",
|
"jouer_son": "Jouer un son",
|
||||||
"declencher_evenement": "Déclencher un événement",
|
"declencher_evenement": "Déclencher un événement",
|
||||||
|
|||||||
@@ -46,5 +46,12 @@ def render_scene_object(obj):
|
|||||||
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>')
|
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>')
|
||||||
src = (obj.get("attributes") or {}).get("src", "")
|
src = (obj.get("attributes") or {}).get("src", "")
|
||||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||||
return (f'<img class="sceneObjectSprite" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
# "fond" (voir add_scene_object.py) : même image fixe que "decor",
|
||||||
|
# seule la classe change — .sceneBackground (static/style.css) la
|
||||||
|
# rend insensible au clic (pointer-events:none), pour qu'elle ne
|
||||||
|
# bloque jamais un clic destiné à un objet posé par-dessus, même
|
||||||
|
# étendue sur tout le "monde" (voir personnage-controller.js, la
|
||||||
|
# caméra qui la suit en défilant).
|
||||||
|
css_class = "sceneObjectSprite sceneBackground" if obj["kind"] == "fond" else "sceneObjectSprite"
|
||||||
|
return (f'<img class="{css_class}" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
||||||
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}>')
|
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}>')
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -3,7 +3,7 @@ import db
|
|||||||
from .ensure_schema import ensure_schema
|
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)
|
ensure_schema(slug)
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
if is_template:
|
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"]
|
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
|
order_index = (max_order or 0) + 1
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO _screens (name, order_index, is_template) VALUES (?, ?, ?)",
|
"INSERT INTO _screens (name, order_index, is_template, kind) VALUES (?, ?, ?, ?)",
|
||||||
(name, order_index, 1 if is_template else 0),
|
(name, order_index, 1 if is_template else 0, kind),
|
||||||
)
|
)
|
||||||
screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ def ensure_schema(slug):
|
|||||||
# (jamais lu par son rendu) — voir screens/scenes/.
|
# (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_width INTEGER NOT NULL DEFAULT 960")
|
||||||
conn.execute("ALTER TABLE _screens ADD COLUMN scene_height INTEGER NOT NULL DEFAULT 540")
|
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()}
|
action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()}
|
||||||
for col_name, col_def in [
|
for col_name, col_def in [
|
||||||
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
|
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Script à usage unique (PAS exécuté au runtime du moteur) : copie les
|
||||||
|
images de fond CraftPix depuis assets/background/<pack>/ (pack source, non
|
||||||
|
versionné dans static/) vers static/backgrounds/<pack_slug>/<image>.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()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user