Merge pull request 'Dev' (#10) from dev into main
Build and deploy / test-python (push) Successful in 6m0s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / build-and-push (push) Successful in 5m24s
Build and deploy / deploy (push) Successful in 56s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-02 19:17:48 +00:00
173 changed files with 8637 additions and 1641 deletions
+5
View File
@@ -27,6 +27,11 @@ data/
# qui les convertit en static/characters/animals/ (ça, c'est committé).
assets/characters/
# Même règle que ci-dessus, pour les images de fond CraftPix — voir
# scripts/generate_background_manifest.py, qui les convertit en
# static/backgrounds/ (ça, c'est committé).
assets/background/
# OS / éditeurs
.DS_Store
Thumbs.db
+23 -10
View File
@@ -19,19 +19,19 @@ from .flask_app import app
# Endpoints accessibles SANS être connecté — tout le reste exige une
# session valide. "static" (CSS/JS/images) doit rester public : la page
# de connexion elle-même en a besoin pour s'afficher.
#
# game_play_public/runtime_payload_public/flow_node_run_data_public/
# flow_node_run_variable_public (routes/public_play/) : la route PUBLIQUE
# /jouer/<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 = {
"static", "login", "login_2fa", "register", "register_2fa", "logout",
"forgot_password", "reset_password",
"game_play_public", "runtime_payload_public",
"flow_node_run_data_public", "flow_node_run_variable_public",
"flow_node_run_add_row_public",
}
# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion
# requise, mais indépendants d'avoir un projet ou non (voir onboarding
# guidé ci-dessous) : un compte pas encore passé par /onboarding doit
# quand même pouvoir changer son mot de passe, son email, ou supprimer
# son compte.
_REACHABLE_WITHOUT_PROJECT = {
"onboarding_new", "profile", "profile_update_name", "profile_update_email",
"profile_regenerate_recovery_codes", "profile_change_password", "profile_delete",
}
@@ -61,9 +61,22 @@ def _require_login_and_enforce_project_isolation():
if user["role"] != "admin":
if endpoint in ("index", "games_new") and user.get("project_slug"):
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un
# compte "user" fraîchement inscrit n'a plus de projet créé
# d'office (voir routes/auth/register_2fa.py) — tant qu'il n'en a
# pas, seules ces routes restent atteignables (toutes exigent déjà
# une connexion, absentes de _PUBLIC_ENDPOINTS).
if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT:
return redirect(url_for("onboarding_new"))
slug = request.view_args.get("slug") if request.view_args else None
if slug is not None and slug != user.get("project_slug"):
abort(403)
# Type d'onboarding "restreint" (quiz/embranchement/rpg, voir
# db/games/game_type_catalog.py) : game_dashboard reste atteignable
# comme pour "custom", mais routes/games/game_dashboard.py y rend
# une version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/
# Éléments de jeu/Variables) — rien à bloquer ici, la restriction
# se fait au niveau du rendu, pas de l'accès à la route.
if endpoint == "game_delete":
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
# recréer un une fois supprimé (games_new le renvoie toujours
-35
View File
@@ -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
+10
View File
@@ -14,3 +14,13 @@ import screens
def forbid_admin_only_character(forge_character):
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin":
abort(403)
# Même règle que forbid_admin_only_character ci-dessus, pour la
# bibliothèque d'images de fond CraftPix (screens.BACKGROUND_LIBRARY,
# voir screens/labels/background_library.py) — TOUJOURS admin-only
# aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement
# aux personnages Forge/Kenney (domaine public, jamais gatés).
def forbid_admin_only_background(background_slug):
if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin":
abort(403)
+28 -4
View File
@@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
vrai INSERT dans cette table.
"""
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
from .slugify import slugify
from .table_name_for import table_name_for
from .game_dir import game_dir
@@ -29,13 +29,16 @@ from .connection import connect
from .games.list_games import list_games
from .games.game_meta import game_meta
from .games.is_public_played import is_public_played
from .games.set_public_played import set_public_played
from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE
from .games.create_game import create_game
from .games.update_game_name import update_game_name
from .games.delete_game import delete_game
from .games.move_game import move_game
from .games.game_type_catalog import (
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
is_restricted,
)
from .definitions.list_definitions import list_definitions
from .definitions.get_definition import get_definition
@@ -66,16 +69,34 @@ from .global_vars.update_global_variable import update_global_variable
from .global_vars.delete_global_variable import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
from .scoring.get_score import get_score
from .scoring.set_score_value import set_score_value
from .scoring.set_status import set_status
from .custom_events.list_custom_events import list_custom_events
from .custom_events.get_custom_event import get_custom_event
from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
from .quests.constants import (
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES,
)
from .quests.list_quests import list_quests
from .quests.get_quest import get_quest
from .quests.create_quest import create_quest
from .quests.update_quest import update_quest
from .quests.delete_quest import delete_quest
from .quests.set_quest_dialogues import set_quest_dialogues
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status",
"slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"is_public_played", "set_public_played", "get_game_type", "DEFAULT_GAME_TYPE",
"get_game_type", "DEFAULT_GAME_TYPE",
"ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type",
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted",
"list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition",
@@ -85,4 +106,7 @@ __all__ = [
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES",
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
"set_quest_dialogues", "sanitize_quest_dialogues",
]
+17 -7
View File
@@ -3,13 +3,9 @@
import os
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur
# joueur autonome (publish/player_app_template.py), qui pose cette
# variable AVANT d'importer db/ pour pointer vers son propre dossier
# "projects/" embarqué plutôt que celui, réel, du poste de développement.
# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable
# d'environnement doit donc déjà être posée avant le tout premier
# `import db`.
# auth/connection.py::FORGE_USERS_DB_PATH). Résolu ici, à l'IMPORT (pas
# par un appel de fonction) : la variable d'environnement doit donc déjà
# être posée avant le tout premier `import db`.
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
)
@@ -41,3 +37,17 @@ GLOBAL_VARIABLE_TYPES = {
"objet": {"label": "Objet (JSON)"},
"tableau": {"label": "Tableau (JSON)"},
}
# Statuts de partie (voir db/scoring/) — préalable aux exports SCORM/xAPI,
# qui ont besoin d'un signal "terminé"/"réussi" propre plutôt que d'une
# convention sur une variable choisie par le créateur (voir le plan
# "Feuille de route produit"). "non_commence" est le défaut d'un joueur
# qui n'a encore rien écrit (voir db/scoring/get_score.py).
SCORE_STATUS_CHOICES = [
("non_commence", "Non commencée"),
("en_cours", "En cours"),
("termine", "Terminée"),
("reussi", "Réussie"),
("echoue", "Échouée"),
]
SCORE_STATUS_LABELS = dict(SCORE_STATUS_CHOICES)
+8
View File
@@ -1,7 +1,15 @@
import os
from .constants import PROJECTS_DIR
from .games.project_slug import split_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, owner_folder, project_part)
+29 -17
View File
@@ -3,30 +3,43 @@ import os
from ..connection import connect
from ..game_dir import game_dir
from ..slugify import slugify
from .get_game_type import DEFAULT_GAME_TYPE
from .project_slug import build_slug
def create_game(name, slug_override=None, game_type=DEFAULT_GAME_TYPE):
def create_game(name, owner_folder=None, project_slug_override=None):
"""Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
slug_override : impose le nom du dossier au lieu de le dériver de
`name` — utilisé par l'inscription (auth/) pour que le dossier d'un
compte porte son adresse email (slugifiée), indépendamment du nom
donné à son jeu. Toujours rendu unique de la même façon (suffixe
-2/-3...) si, par un hasard extrême, le dossier existe déjà.
owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py —
structure de dossiers par utilisateur), typiquement
slugify(email_du_compte) — le slug final composé
"<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué
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"
(éditeur de scène — voir get_game_type.py) — choisi une fois pour
toutes à la création, gate quel éditeur/quel rendu jouable ce jeu
utilise (voir routes/screens/screen_edit.py)."""
slug = slugify(slug_override) if slug_override else slugify(name)
base = slug
project_slug_override : impose le nom du dossier PROJET au lieu de le
dériver de `name` — utilisé par l'inscription (auth/, voir
routes/onboarding/) pour que le tout premier projet d'un compte porte
un nom de dossier prévisible. Le suffixe d'unicité (-2/-3...) ne
compare que parmi les projets déjà présents dans CE `owner_folder`
(deux comptes différents peuvent donc avoir chacun un projet nommé
pareil, sans collision).
Un projet n'a plus de "type" propre (voir screens/screens_repo/
ensure_schema.py — fusion des moteurs, "document"/"jeu_2d" est
désormais une propriété PAR ÉCRAN) : un projet fraîchement créé n'a
par défaut aucun écran, c'est à l'appelant d'en créer un premier avec
le "kind" voulu (voir screens.create_screen)."""
project_part = slugify(project_slug_override) if project_slug_override else slugify(name)
base = project_part
n = 2
while os.path.isdir(game_dir(base)):
base = f"{slug}-{n}"
while os.path.isdir(game_dir(build_slug(owner_folder, base) if owner_folder else base)):
base = f"{project_part}-{n}"
n += 1
slug = base
project_part = base
slug = build_slug(owner_folder, project_part) if owner_folder else project_part
os.makedirs(game_dir(slug), exist_ok=True)
@@ -84,7 +97,6 @@ console.log("Jeu chargé.");
"""
)
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
conn.execute("INSERT INTO _meta (key, value) VALUES ('game_type', ?)", (game_type,))
conn.commit()
conn.close()
return slug
+13
View File
@@ -1,7 +1,20 @@
import os
import shutil
from ..constants import PROJECTS_DIR
from ..game_dir import game_dir
from .project_slug import split_slug
def delete_game(slug):
shutil.rmtree(game_dir(slug))
# Structure de dossiers par utilisateur (voir project_slug.py) :
# nettoie aussi le dossier propriétaire s'il ne contient plus aucun
# projet — purement cosmétique (game_dir()/list_games() fonctionnent
# très bien avec un dossier propriétaire vide), évite juste
# d'accumuler des dossiers fantômes sous projects/.
owner_folder, project_part = split_slug(slug)
if project_part is not None:
owner_path = os.path.join(PROJECTS_DIR, owner_folder)
if os.path.isdir(owner_path) and not os.listdir(owner_path):
os.rmdir(owner_path)
+8 -11
View File
@@ -1,6 +1,5 @@
from ..connection import connect
from .is_public_played import is_public_played
from .get_game_type import get_game_type
from .game_type_catalog import get_onboarding_type
def game_meta(slug):
@@ -9,13 +8,11 @@ def game_meta(slug):
conn.close()
return {
"slug": slug, "name": row["value"] if row else slug,
# État par joueur (Phase 1) : disponible partout où `game` est
# dans le contexte du template (base.html, la bascule "Publier en
# ligne" en a besoin pour afficher son état actuel), sans avoir à
# appeler is_public_played(slug) séparément dans chaque route.
"is_public_played": is_public_played(slug),
# Type de jeu (fondations multi-éditeurs) : "document" (éditeur
# générique) ou "jeu_2d" (éditeur de scène) — voir
# get_game_type.py et routes/screens/screen_edit.py.
"game_type": get_game_type(slug),
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
# templates/base.html pour cacher le lien "Tableau de bord" à un
# compte "restreint" (quiz/embranchement/rpg).
"onboarding_type": get_onboarding_type(slug),
# Pas de "game_type" ici : depuis la fusion des moteurs (voir
# screens/screens_repo/ensure_schema.py), "document"/"jeu_2d" est
# une propriété PAR ÉCRAN (screen["kind"]), plus du projet.
}
+98
View File
@@ -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"]
+4 -3
View File
@@ -8,9 +8,10 @@ def get_game_type(slug):
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
voir screens/scenes/, écrans = objets pixel-positionnés en couches).
Réutilise _meta comme is_public_played — aucune nouvelle table. Un jeu
créé avant l'existence de cette clé (absente de _meta) retombe sur
"document", son comportement d'avant, inchangé."""
Stocké dans _meta (une table générique clé/valeur, déjà utilisée pour
d'autres réglages de projet) — aucune nouvelle table. Un jeu créé avant
l'existence de cette clé (absente de _meta) retombe sur "document",
son comportement d'avant, inchangé."""
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'game_type'").fetchone()
conn.close()
-14
View File
@@ -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
View File
@@ -3,18 +3,35 @@ import os
from ..constants import PROJECTS_DIR
from ..connection import connect
from ..db_path import db_path
from .project_slug import build_slug
def list_games():
"""Scanne le dossier projects/ : chaque sous-dossier avec un game.db est un jeu."""
"""Scanne projects/ : structure par utilisateur (voir
db/games/project_slug.py) — projects/<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 = []
if not os.path.isdir(PROJECTS_DIR):
return games
for slug in sorted(os.listdir(PROJECTS_DIR)):
path = db_path(slug)
if os.path.isfile(path):
for entry in sorted(os.listdir(PROJECTS_DIR)):
entry_path = os.path.join(PROJECTS_DIR, entry)
if not os.path.isdir(entry_path):
continue
if os.path.isfile(os.path.join(entry_path, "game.db")):
# Repli : un ancien dossier plat porte directement game.db.
_append_game(games, entry)
continue
for project_part in sorted(os.listdir(entry_path)):
slug = build_slug(entry, project_part)
if os.path.isfile(db_path(slug)):
_append_game(games, slug)
return games
def _append_game(games, slug):
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
games.append({"slug": slug, "name": row["value"] if row else slug})
return games
+33 -13
View File
@@ -1,23 +1,43 @@
import os
from ..constants import PROJECTS_DIR
from ..game_dir import game_dir
from .project_slug import build_slug, split_slug
def move_game(old_slug, new_slug):
"""Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un
def move_game(old_slug, new_owner_folder):
"""Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un
utilisateur change son adresse email (voir routes/auth/profile.py,
project_slug = slugify(email) pour un compte "user") : le dossier
physique du jeu doit suivre. Rend le nouveau slug unique de la même
façon que create_game() si, par un hasard extrême, il correspond déjà
à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé),
à enregistrer comme nouveau project_slug."""
if old_slug == new_slug:
owner_folder = slugify(email) pour un compte "user", voir
db/games/project_slug.py) : le dossier physique doit suivre. Renomme
le dossier propriétaire ENTIER en un coup (déplace tous les projets de
ce compte ensemble — prêt pour un futur multi-projet, même si un seul
aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire
unique de la même façon que create_game() si, par un hasard extrême,
il correspond déjà à un dossier existant. Renvoie le nouveau slug
composé FINAL (même projet, propriétaire renommé), à enregistrer comme
nouveau project_slug."""
old_owner_folder, project_part = split_slug(old_slug)
if project_part is None:
# Slug pas encore migré vers la structure par utilisateur (voir
# scripts/migrate_flat_project_slugs.py) — repli sur l'ancien
# comportement plat (renomme le dossier du projet lui-même).
if old_slug == new_owner_folder:
return old_slug
base = new_slug
base = new_owner_folder
n = 2
while os.path.isdir(game_dir(base)):
base = f"{new_slug}-{n}"
base = f"{new_owner_folder}-{n}"
n += 1
new_slug = base
os.rename(game_dir(old_slug), game_dir(new_slug))
return new_slug
os.rename(game_dir(old_slug), game_dir(base))
return base
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)
+32
View File
@@ -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
-14
View File
@@ -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()
+5
View File
@@ -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"]
+18
View File
@@ -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)
+10
View File
@@ -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()
+29
View File
@@ -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()
+11
View File
@@ -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
+22
View File
@@ -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]
+30
View File
@@ -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
+21
View File
@@ -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
+42
View File
@@ -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)
+6
View File
@@ -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)."""
+22
View File
@@ -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()
+17
View File
@@ -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)
+23
View File
@@ -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
+25
View File
@@ -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
View File
@@ -1,3 +1,3 @@
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
"""publish — empaquette un jeu en export Web/SCORM (voir
build_scorm_package.py) : le bouton "Exporter (Web/SCORM)" de la barre
de navigation d'un jeu (templates/base.html)."""
-165
View File
@@ -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"]
+271
View File
@@ -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"]
-107
View File
@@ -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)
-4
View File
@@ -1,4 +0,0 @@
@echo off
cd /d "%~dp0"
python-embed\python.exe player_app.py
pause
+48
View File
@@ -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,
)
-91
View File
@@ -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
View File
@@ -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
+5
View File
@@ -52,5 +52,10 @@ def login_2fa():
return redirect(next_url)
if user["role"] == "admin":
return redirect(url_for("index"))
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) :
# un compte "user" peut ne pas encore avoir de projet du tout
# (inscription interrompue avant l'onboarding).
if not user.get("project_slug"):
return redirect(url_for("onboarding_new"))
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
return render_template("auth/login_2fa.html", error=error)
+6 -4
View File
@@ -1,5 +1,4 @@
import os
import shutil
from flask import g, redirect, render_template, request, session, url_for
@@ -106,9 +105,12 @@ def profile_delete():
# plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses
# projets restent intacts.
if user["role"] != "admin" and user.get("project_slug"):
game_dir = db.game_dir(user["project_slug"])
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
if os.path.isdir(db.game_dir(user["project_slug"])):
# db.delete_game (voir db/games/project_slug.py — structure de
# dossiers par utilisateur) nettoie aussi le dossier
# propriétaire s'il devient vide, contrairement à un simple
# shutil.rmtree du dossier projet seul.
db.delete_game(user["project_slug"])
auth.delete_user(user["id"])
session.clear()
+8 -12
View File
@@ -1,7 +1,6 @@
from flask import redirect, render_template, request, session, url_for
import auth
import db
from core.flask_app import app
@@ -28,16 +27,6 @@ def register_2fa():
else:
auth.reset_failed_attempts(user_id)
auth.confirm_totp(user_id)
# Un compte "user" (pas "admin") reçoit ICI son unique projet,
# nommé d'après son adresse email (voir create_user.py — le
# rôle est déjà tranché à la création : premier compte jamais
# créé = admin, tous les suivants = user) — un admin, lui,
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
# aucun projet ne lui est imposé d'office.
if user["role"] != "admin":
slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"])
auth.set_project_slug(user_id, slug)
user = auth.get_user_by_id(user_id)
session.pop("pending_register_user_id", None)
session["user_id"] = user_id
# Générés ici, une seule fois, à l'instant même où la 2FA
@@ -50,7 +39,14 @@ def register_2fa():
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id)
if user["role"] == "admin":
return redirect(url_for("index"))
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
# Un compte "user" (pas "admin") n'a plus de projet créé
# d'office ici — voir routes/onboarding/onboarding_new.py : il
# choisit d'abord un type de jeu et nomme son projet, PUIS
# auth.set_project_slug() y est appelé (core/auth_guard.py
# renvoie ici tout compte sans project_slug). Un admin, lui,
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
# aucun projet ne lui est imposé d'office.
return redirect(url_for("onboarding_new"))
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
return render_template(
+2
View File
@@ -6,6 +6,8 @@ from . import (
flow_edge_delete,
flow_node_run_data,
flow_node_run_variable,
flow_node_run_score,
flow_node_run_status,
flow_node_run_add_row,
runtime_payload,
)
+18
View File
@@ -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})
+17
View File
@@ -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
View File
@@ -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
+35 -1
View File
@@ -13,8 +13,42 @@ def game_dashboard(slug):
accès direct à chaque élément — la barre de navigation du jeu (voir
base.html) permet déjà de sauter directement vers chaque éditeur, cette
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
liens."""
liens.
Onboarding guidé (voir db/games/game_type_catalog.py) : un compte
"restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE —
juste ses écrans, en cartes avec un aperçu de leur contenu, plus un
bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le
projet : "jeu_2d" pour un RPG, "document" pour les autres — voir
routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/
variables restent réservés à "Créer mon jeu de A à Z", la seule
formule pensée pour la puissance complète du moteur."""
game = db.game_meta(slug)
if db.is_restricted(slug):
return _render_simple_dashboard(slug, game)
return _render_full_dashboard(slug, game)
def _render_simple_dashboard(slug, game):
screen_list = screens.list_screens(slug)
previews = {}
for s in screen_list:
if s["kind"] == "jeu_2d":
objects = screens.list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
else:
previews[s["id"]] = {
"kind": "document",
"elements": screens.list_elements(slug, s["id"]),
}
return render_template(
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
)
def _render_full_dashboard(slug, game):
definitions = db.list_definitions(slug)
# Détail complet (avec ses champs) de chaque objet, pour le panneau
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
+16
View File
@@ -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))
-16
View File
@@ -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))
+9 -5
View File
@@ -1,4 +1,4 @@
from flask import request, redirect, url_for
from flask import g, request, redirect, url_for
import db
@@ -10,8 +10,12 @@ def games_new():
name = request.form.get("name", "").strip()
if not name:
return redirect(url_for("index"))
game_type = request.form.get("game_type", db.DEFAULT_GAME_TYPE)
if game_type not in ("document", "jeu_2d"):
game_type = db.DEFAULT_GAME_TYPE
slug = db.create_game(name, game_type=game_type)
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# jeu n'a plus de type propre — aucun écran créé d'office ici, comme
# avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en
# choisissant "document" ou "jeu_2d" pour chacun).
# owner_folder (voir db/games/project_slug.py — structure de dossiers
# par utilisateur) : posé par core/auth_guard.py pour tout compte
# connecté, y compris l'admin qui utilise cette route.
slug = db.create_game(name, owner_folder=db.slugify(g.current_user["email"]))
return redirect(url_for("game_dashboard", slug=slug))
+1
View File
@@ -0,0 +1 @@
from . import onboarding_new
+66
View File
@@ -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)
+3
View File
@@ -18,8 +18,11 @@ def game_play(slug):
payload = screens.full_game_payload(slug)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
asset_url=lambda filename: url_for("static", filename=filename),
runtime_payload_url=url_for("runtime_payload", slug=slug),
flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999),
flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999),
flow_node_run_score_url=url_for("flow_node_run_score", slug=slug, node_id=999999),
flow_node_run_status_url=url_for("flow_node_run_status", slug=slug, node_id=999999),
flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999),
)
-7
View File
@@ -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
-37
View File
@@ -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
View File
@@ -1 +1 @@
from . import publish_game
from . import export_scorm
+27
View File
@@ -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")
-28
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
from . import quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues
+11
View File
@@ -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})
+17
View File
@@ -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})
+12
View File
@@ -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})
+14
View File
@@ -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})
+15
View File
@@ -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)})
+4
View File
@@ -3,4 +3,8 @@ from . import (
scene_object_geometry,
scene_object_delete,
scene_object_personnage_data,
scene_object_commands,
scene_object_role,
scene_object_collision,
scene_object_collision_rules,
)
+58 -7
View File
@@ -8,12 +8,13 @@ import screens
def render_scene_edit(slug, screen_id, game):
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
screen_edit.py (le dispatcher, selon game["game_type"]), PAS une route
screen_edit.py (le dispatcher, selon screen["kind"]), PAS une route
Flask elle-même : même URL/endpoint "screen_edit" pour les deux types
de jeu. Réutilise TEL QUEL tout ce qui est déjà générique côté logique
de flow/animation/variables/événements (voir le plan "Fondations
d'une plateforme multi-éditeurs") — seuls les objets de scène et la
palette de nœuds proposée diffèrent de l'éditeur document."""
d'écran. Réutilise TEL QUEL tout ce qui est déjà générique côté logique
de flow/animation/variables/événements (voir screens/screens_repo/
ensure_schema.py — le type d'écran est une propriété PAR ÉCRAN) —
seuls les objets de scène et la palette de nœuds proposée diffèrent de
l'éditeur document."""
screen = screens.get_screen(slug, screen_id)
if not screen:
abort(404)
@@ -22,18 +23,49 @@ def render_scene_edit(slug, screen_id, game):
o["rendered_html"] = screens.render_scene_object(o)
selected_id = request.args.get("selected", type=int)
personnage_data = None
personnage_commands = None
personnage_role = None
collision_settings = None
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
if selected_obj and selected_obj["kind"] == "personnage":
if selected_obj:
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(selected_obj)
personnage_role = screens.resolve_personnage_role(selected_obj)
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
object_labels = {
o["id"]: (o.get("name") or ("Personnage" if o["kind"] == "personnage" else "Décor"))
o["id"]: (o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"))
for o in objects
}
element_animations_map = {
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
}
# "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet
# ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
# TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
# screens/rendering/collision_rules.py) — construit ici plutôt que
# réutiliser `objects` tel quel, pour ne poser resolve_personnage_role
# qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
collision_rule_objects = []
for o in objects:
if o["kind"] == "fond":
continue
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
if role == "joueur":
continue
collision_rule_objects.append({
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
})
flow_blocks = screens.list_flow_blocks(slug, screen_id)
flow_nodes = screens.list_flow_nodes(slug, screen_id)
flow_edges = screens.list_flow_edges(slug, screen_id)
@@ -51,13 +83,21 @@ def render_scene_edit(slug, screen_id, game):
# "Bibliothèque de sprites animaux CraftPix").
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
sprite_library_families = screens.sprite_gallery_families(sprite_library)
# Images de fond CraftPix (screens.BACKGROUND_LIBRARY) : même règle
# d'accès que les sprites animaux — réservées au rôle admin.
background_library = screens.BACKGROUND_LIBRARY if g.current_user["role"] == "admin" else {}
return render_template(
"scene_edit.html",
game=game, screen=screen, all_screens=all_screens,
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
background_library=background_library,
element_animations_map_json=json.dumps(element_animations_map),
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
@@ -68,6 +108,8 @@ def render_scene_edit(slug, screen_id, game):
element_action_property_labels_json=json.dumps(dict(screens.OBJECT_ACTION_PROPERTIES)),
condition_operators=screens.CONDITION_OPERATORS,
data_operations=screens.DATA_OPERATIONS, data_operation_labels=screens.DATA_OPERATION_LABELS,
score_status_choices=db.SCORE_STATUS_CHOICES,
score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS),
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS_2D),
@@ -83,4 +125,13 @@ def render_scene_edit(slug, screen_id, game):
definitions=db.list_definitions(slug),
definitions_json=json.dumps({str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in db.list_definitions(slug)}),
definitions_names_json=json.dumps({d["id"]: d["name"] for d in db.list_definitions(slug)}),
collision_rule_objects_json=json.dumps(collision_rule_objects),
collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES,
collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX,
# Éditeur de quêtes (voir db/quests/) : préchargées ici pour que
# collisionRuleActionSummary (collision-rules-editor.js) affiche
# tout de suite le TITRE de la quête référencée par une règle
# existante, sans attendre l'ouverture de l'assistant "+ Action"
# (seul moment où questPickerHtml irait la chercher sinon).
quests_json=json.dumps(db.list_quests(slug)),
)
+7 -2
View File
@@ -3,7 +3,7 @@ from flask import request, redirect, url_for
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_background
@app.route("/game/<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)."""
kind = request.form.get("kind", "personnage")
forge_character = request.form.get("forge_character")
background_slug = request.form.get("background_slug")
if forge_character:
forbid_admin_only_character(forge_character)
new_id = screens.add_scene_object(slug, screen_id, kind=kind, forge_character=forge_character)
if background_slug:
forbid_admin_only_background(background_slug)
new_id = screens.add_scene_object(
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
)
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
+34
View File
@@ -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})
+40
View File
@@ -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})
+22
View File
@@ -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})
+10 -6
View File
@@ -12,15 +12,17 @@ from ..scenes.scene_edit_view import render_scene_edit
@app.route("/game/<slug>/screens/<int:screen_id>/edit")
def screen_edit(slug, screen_id):
game = db.game_meta(slug)
if game["game_type"] == "jeu_2d":
# Fondations multi-éditeurs (voir db/games/get_game_type.py) :
# même route/endpoint pour les deux types de jeu, dispatch ici —
# tout le reste de cette fonction ne concerne QUE l'éditeur
# document ("document", le défaut, comportement inchangé).
return render_scene_edit(slug, screen_id, game)
screen = screens.get_screen(slug, screen_id)
if not screen:
abort(404)
if screen["kind"] == "jeu_2d":
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
# le type d'écran ("document" ou "jeu_2d") est une propriété de
# L'ÉCRAN, pas du projet — même route/endpoint pour les deux,
# dispatch ici selon l'écran demandé — tout le reste de cette
# fonction ne concerne QUE l'éditeur document (comportement
# inchangé pour un écran "document").
return render_scene_edit(slug, screen_id, game)
elements = screens.list_elements(slug, screen_id)
all_screens = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
@@ -106,6 +108,8 @@ def screen_edit(slug, screen_id):
element_animations_map_json=json.dumps(element_animations_map),
data_operations=screens.DATA_OPERATIONS,
data_operation_labels=screens.DATA_OPERATION_LABELS,
score_status_choices=db.SCORE_STATUS_CHOICES,
score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS),
definitions=definitions, definitions_json=json.dumps(definitions_data),
selected_id=selected_id, selected_controls=selected_controls,
flow_nodes=flow_nodes, flow_edges=flow_edges,
+14 -1
View File
@@ -1,5 +1,6 @@
from flask import request, redirect, url_for
import db
import screens
from core.flask_app import app
@@ -8,7 +9,19 @@ from core.flask_app import app
@app.route("/game/<slug>/screens/new", methods=["POST"])
def screens_new(slug):
name = request.form.get("name", "").strip()
if db.is_restricted(slug):
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
# imposé par le projet (jeu_2d pour un RPG, document pour les
# autres), même valeur pour TOUS ses écrans (voir
# db/games/game_type_catalog.py).
onboarding_type = db.get_onboarding_type(slug)
kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
else:
kind = request.form.get("kind", "document")
if kind not in ("document", "jeu_2d"):
kind = "document"
if name:
screen_id = screens.create_screen(slug, name)
screen_id = screens.create_screen(slug, name, kind=kind)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
+18
View File
@@ -67,6 +67,11 @@ from .labels.animal_sprite_library import ADMIN_SPRITE_LIBRARY
SPRITE_LIBRARY = {**PUBLIC_SPRITE_LIBRARY, **ADMIN_SPRITE_LIBRARY}
ADMIN_ONLY_CHARACTER_SLUGS = set(ADMIN_SPRITE_LIBRARY)
from .labels.sprite_gallery import sprite_gallery_families
from .labels.background_library import BACKGROUND_LIBRARY
# Un seul pack aujourd'hui, licence CraftPix — tout BACKGROUND_LIBRARY est
# admin-only (voir core/sprite_gate.py::forbid_admin_only_background),
# jamais de version "publique" comme pour les personnages Kenney.
ADMIN_ONLY_BACKGROUND_SLUGS = set(BACKGROUND_LIBRARY)
from .rendering.list_used_forge_characters import list_used_forge_characters
from .flow.constants import (
@@ -76,6 +81,13 @@ from .flow.constants import (
from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
from .rendering.collision_settings import resolve_collision_settings, DEFAULT_COLLISION_SETTINGS, COLLISION_SHAPES
from .rendering.collision_rules import (
resolve_collision_rules, sanitize_collision_rules,
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
)
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
from .screens_repo.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens
@@ -109,6 +121,10 @@ from .scenes.get_scene_object import get_scene_object
from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .scenes.delete_scene_object import delete_scene_object
from .scenes.set_scene_object_personnage_data import set_scene_object_personnage_data
from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.set_scene_object_role import set_scene_object_role
from .scenes.set_scene_object_collision import set_scene_object_collision
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
from .scenes.render_scene_object import render_scene_object
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .elements.element_label import element_label
@@ -144,6 +160,8 @@ from .flow.blocks.blocks_view import blocks_view
from .data_actions.apply_data_action import apply_data_action
from .data_actions.apply_variable_action import apply_variable_action
from .data_actions.apply_score_action import apply_score_action
from .data_actions.apply_status_action import apply_status_action
from .data_actions.apply_add_row_action import apply_add_row_action
from .animations.ensure_animation_schema import ensure_animation_schema
@@ -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)
+2
View File
@@ -5,6 +5,8 @@ ACTION_TYPE_LABELS = {
"modifier_element": "Modifier un élément",
"modifier_donnee": "Modifier une donnée d'un objet",
"modifier_variable": "Modifier une variable globale",
"modifier_score": "Modifier le score",
"definir_statut_partie": "Définir le statut de la partie",
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer",
+1 -1
View File
@@ -24,7 +24,7 @@ def _load_manifest():
except FileNotFoundError:
# Poste de dev sans les assets animaux copiés (script pas encore
# lancé), ou paquet exporté pour un jeu qui n'utilise aucun
# personnage animal (voir publish/build_package.py, copie
# personnage animal (voir publish/build_scorm_package.py, copie
# sélective) : aucun personnage animal disponible, pas une erreur.
return {}
+52
View File
@@ -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()
+99 -22
View File
@@ -6,42 +6,71 @@ from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
from ..element_types.load_template_tree import _load_template_tree
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings
from ..rendering.collision_rules import resolve_collision_rules
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
def full_game_payload(slug, player_id=db.PLAYER_SHARED):
"""Toutes les données nécessaires au runtime JS de la page de jeu jouable
(/game/<slug>/play, ou /jouer/<slug> — voir routes/public_play/) : chaque
écran avec ses éléments (HTML déjà généré, y compris les Répéteurs de
données, lus en direct), le graphe de logique (nœuds + fils) de chaque
écran, et un instantané des données du jeu (avec le type de chaque
champ, nécessaire pour évaluer une condition correctement) — un seul
aller-retour serveur, ensuite tout se joue côté client (aucune
navigation ne recharge la page, sauf pour appliquer une action "modifier
une donnée", qui doit passer par le serveur).
(/game/<slug>/play, ou le paquet Web/SCORM exporté — voir
publish/build_scorm_package.py) : chaque écran avec ses éléments (HTML
déjà généré, y compris les Répéteurs de données, lus en direct), le
graphe de logique (nœuds + fils) de chaque écran, et un instantané des
données du jeu (avec le type de chaque champ, nécessaire pour évaluer
une condition correctement) — un seul aller-retour serveur, ensuite
tout se joue côté client (aucune navigation ne recharge la page, sauf
pour appliquer une action "modifier une donnée", qui doit passer par
le serveur).
player_id (état par joueur, Phase 1) : PLAYER_SHARED pour l'aperçu
créateur (comportement inchangé — une seule partie partagée pendant la
conception), l'identifiant réel du visiteur pour une partie publique
(voir core/player_identity.py) — chaque objet/variable per_player
renvoie alors la valeur propre à CE joueur."""
# Fondations multi-éditeurs — un jeu "jeu_2d" (voir db/games/
# get_game_type.py) construit "elements" depuis _scene_objects
# (objets de scène en pixels) plutôt que _screen_elements (DOM en %) ;
player_id (état par joueur, Phase 1) : toujours PLAYER_SHARED
aujourd'hui (une seule partie partagée, pour la conception comme pour
un paquet exporté) — le paramètre reste générique pour tout objet/
variable per_player (voir db/global_vars/, db/rows/), qui renvoie
alors la valeur propre à CE joueur."""
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# écran "jeu_2d" construit "elements" depuis _scene_objects (objets de
# scène en pixels) plutôt que _screen_elements (DOM en %) — décidé
# ÉCRAN PAR ÉCRAN (s["kind"]), un même jeu peut mélanger les deux ;
# tout le reste de cette fonction (flows/animations/variables/data)
# est déjà générique et inchangé pour les deux types de jeu.
is_scene_game = db.get_game_type(slug) == "jeu_2d"
# est déjà générique et inchangé pour les deux types d'écran.
screens_ = list_screens(slug)
payload_screens = []
flows = {}
animations = {}
for s in screens_:
if is_scene_game:
if s["kind"] == "jeu_2d":
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches et son rôle déjà résolus
# (défauts comblés) pour que le runtime client n'ait
# aucune logique de repli à dupliquer — seul un
# personnage "joueur" est déplacé/suivi par la caméra.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
o["personnage_role"] = resolve_personnage_role(o)
# Boîte de collision (voir static/js/play/conditions.js::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# seulement "personnage" — un décor/fond peut aussi servir
# d'obstacle.
o["collision"] = resolve_collision_settings(o)
# Règles "à la collision/dans un périmètre -> action" (voir
# "🧩 Collision", templates/scene_edit.html, et
# static/js/play/collision-rules-controller.js pour leur
# exécution) — jamais sur "fond" (jamais une cible), ni sur
# le personnage "joueur" lui-même (ce sont TOUJOURS les
# AUTRES objets qui réagissent à SA présence, jamais
# l'inverse).
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
o["collision_rules"] = resolve_collision_rules(o)
payload_screens.append({**s, "elements": objects})
else:
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
@@ -80,7 +109,7 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
"edges": list_flow_edges(slug, s["id"]),
}
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
if is_scene_game:
if s["kind"] == "jeu_2d":
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage":
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
@@ -95,12 +124,37 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
for d in definitions:
full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full, player_id)
cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]}
# Un champ "relation" vit dans une colonne SQL "<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"])] = [
{**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]}
for r in rows
]
fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]]
# min_value/max_value (2.2 — bornage automatique, voir
# apply_data_action.py) : nécessaires ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
# voir le plan "port complet du runtime jouable côté navigateur")
# puisse reproduire le même bornage sans aller-retour serveur —
# inoffensif pour le runtime en ligne, qui ignore ces deux clés.
fields_meta[str(d["id"])] = [
{"name": f["name"], "type": f["type"], "min_value": f.get("min_value"), "max_value": f.get("max_value")}
for f in full["fields"]
]
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
@@ -111,6 +165,21 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
# Arbre complet (éléments + descendants) de l'écran-modèle de chaque
# élément de jeu réutilisable — voir screens/element_types/
# load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu
# créateur ET jeu publié en ligne). Nécessaire ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
# rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de
# Répéteur) puisse le rejouer sans base de données — inoffensif pour
# le runtime en ligne, qui l'ignore.
element_type_templates = {}
for type_id_str, template_screen_id in element_types.items():
if template_screen_id:
tree = _load_template_tree(slug, int(type_id_str))
if tree:
element_type_templates[type_id_str] = tree
# Instantané des variables globales, pour qu'un nœud Condition puisse en
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
@@ -122,8 +191,16 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
for v in db.list_global_variables_for_player(slug, player_id)
}
# id de définition -> nom de l'objet (voir filter_repeater_rows.py côté
# serveur, static/js/play/offline/filter-repeater-rows.js côté port
# hors ligne) : nécessaire pour résoudre une référence "{{Objet.champ}}"
# (Répéteur/Donnée liée/Condition de visibilité) — data/fields_meta
# ci-dessus sont déjà indexés par id, jamais par nom.
definition_names = {str(d["id"]): d["name"] for d in definitions}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations,
"definition_names": definition_names, "element_type_templates": element_type_templates,
}
+108
View File
@@ -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)
+40
View File
@@ -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
(même patron que full_game_payload.py : un personnage peut vivre dans
un écran-modèle d'élément réutilisable). Utilisé par
publish/build_package.py pour ne copier dans le .zip exporté que les
publish/build_scorm_package.py pour ne copier dans le .zip exporté que les
dossiers static/characters/animals/<animal>/<NN>/ réellement utilisés
par CE jeu, plutôt que les ~52 000 fichiers de toute la bibliothèque
(voir le plan "Bibliothèque de sprites animaux CraftPix")."""
+58
View File
@@ -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
+20
View File
@@ -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
+30 -8
View File
@@ -5,20 +5,27 @@ import db
from .ensure_scene_schema import ensure_scene_schema
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
_KINDS = {"personnage", "decor"}
_KINDS = {"personnage", "decor", "fond"}
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None):
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
"""Pose un nouvel objet sur une scène (voir ensure_scene_schema.py).
kind="personnage" : attributes._personnage_data, MÊME structure/mêmes
fonctions resolve_personnage_* que le widget "personnage" de l'éditeur
document (screens/rendering/personnage_data.py) — un objet de scène
"personnage" partage donc directement la bibliothèque Forge et le
système d'animation déjà écrits. kind="decor" : une simple image fixe
(attributes.src), pas d'animation."""
(attributes.src), pas d'animation. kind="fond" : même image fixe que
"decor" (render_scene_object.py ne les distingue pas au rendu), mais
posée en dessous de tout le reste (z_index très négatif, voir
static/style.css .sceneBackground) et à la taille RÉELLE de l'image
choisie (voir screens/labels/background_library.py) par défaut — une
image plus grande que la scène devient alors le "monde" que la caméra
suit en défilant (voir static/js/play/personnage-controller.js)."""
ensure_scene_schema(slug)
kind = kind if kind in _KINDS else "personnage"
attributes = {}
width = height = None
if kind == "personnage":
data = dict(_DEFAULT_PERSONNAGE_DATA)
if forge_character:
@@ -27,15 +34,30 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None):
if forge_character in PUBLIC_SPRITE_LIBRARY or forge_character in ADMIN_SPRITE_LIBRARY:
data["forge_character"] = forge_character
attributes["_personnage_data"] = json.dumps(data)
elif kind == "fond" and background_slug:
from ..labels.background_library import BACKGROUND_LIBRARY
background = BACKGROUND_LIBRARY.get(background_slug)
if background:
attributes["src"] = background["url"]
width, height = background["width"], background["height"]
conn = db.connect(slug)
if kind == "fond":
z_index = -1000
else:
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _scene_objects WHERE scene_id = ?", (scene_id,)
).fetchone()["m"]
conn.execute(
"""INSERT INTO _scene_objects (scene_id, kind, attributes, z_index)
VALUES (?, ?, ?, ?)""",
(scene_id, kind, json.dumps(attributes), (max_z or 0) + 1),
)
z_index = (max_z or 0) + 1
cols = ["scene_id", "kind", "attributes", "z_index"]
values = [scene_id, kind, json.dumps(attributes), z_index]
if width is not None:
# x=0/y=0 (jamais le défaut 100/100, voir ensure_scene_schema.py) :
# un fond commence au coin haut-gauche de la scène/du monde, pas
# décalé arbitrairement comme un objet ordinaire.
cols += ["width", "height", "x", "y"]
values += [width, height, 0, 0]
placeholders = ", ".join(["?"] * len(values))
conn.execute(f"INSERT INTO _scene_objects ({', '.join(cols)}) VALUES ({placeholders})", values)
object_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
+2
View File
@@ -20,6 +20,8 @@ ACTION_TYPE_LABELS_2D = {
"modifier_objet_scene": "Modifier un objet de scène",
"modifier_variable": "Modifier une variable globale",
"modifier_donnee": "Modifier une donnée d'un objet",
"modifier_score": "Modifier le score",
"definir_statut_partie": "Définir le statut de la partie",
"jouer_animation_sprite": "Jouer une animation de sprites",
"jouer_son": "Jouer un son",
"declencher_evenement": "Déclencher un événement",
+8 -1
View File
@@ -46,5 +46,12 @@ def render_scene_object(obj):
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>')
src = (obj.get("attributes") or {}).get("src", "")
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
return (f'<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}>')
@@ -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()
+25
View File
@@ -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 -3
View File
@@ -3,7 +3,7 @@ import db
from .ensure_schema import ensure_schema
def create_screen(slug, name, is_template=False):
def create_screen(slug, name, is_template=False, kind="document"):
ensure_schema(slug)
conn = db.connect(slug)
if is_template:
@@ -12,8 +12,8 @@ def create_screen(slug, name, is_template=False):
max_order = conn.execute("SELECT MAX(order_index) AS m FROM _screens WHERE is_template = 0").fetchone()["m"]
order_index = (max_order or 0) + 1
conn.execute(
"INSERT INTO _screens (name, order_index, is_template) VALUES (?, ?, ?)",
(name, order_index, 1 if is_template else 0),
"INSERT INTO _screens (name, order_index, is_template, kind) VALUES (?, ?, ?, ?)",
(name, order_index, 1 if is_template else 0, kind),
)
screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
+9
View File
@@ -123,6 +123,15 @@ def ensure_schema(slug):
# (jamais lu par son rendu) — voir screens/scenes/.
conn.execute("ALTER TABLE _screens ADD COLUMN scene_width INTEGER NOT NULL DEFAULT 960")
conn.execute("ALTER TABLE _screens ADD COLUMN scene_height INTEGER NOT NULL DEFAULT 540")
if "kind" not in screen_cols:
# Fusion des moteurs — "document" (écran %) ou "jeu_2d" (scène
# pixels) devient une propriété PAR ÉCRAN plutôt que par projet
# (voir db/games/get_game_type.py, désormais lu UNIQUEMENT ici,
# pour la rétro-compatibilité) : un même jeu peut mélanger les
# deux types d'écran librement (voir routes/screens/screen_edit.py,
# qui dispatche maintenant sur screen["kind"]).
conn.execute("ALTER TABLE _screens ADD COLUMN kind TEXT NOT NULL DEFAULT 'document'")
conn.execute("UPDATE _screens SET kind = ?", (db.get_game_type(slug),))
action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()}
for col_name, col_def in [
("target_element_id", "INTEGER REFERENCES _screen_elements(id)"),
+41
View File
@@ -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()
+80
View File
@@ -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