Export Web/SCORM : port complet du runtime jouable côté navigateur
Phase 1.2 de la feuille de route produit — un paquet SCORM tourne seul dans le LMS du client, sans serveur Forge Engine disponible. Ajoute static/js/play/offline/ (miroir JS de screens/rendering/ et data_actions/, sous window.FORGE_OFFLINE) pour que variables, score, données d'objet, répéteurs et conditions de visibilité fonctionnent entièrement en mémoire côté navigateur ; branche actions.js et bindings.js dessus au lieu d'un fetch() serveur. Ajoute publish/build_scorm_package.py (paquet statique + imsmanifest.xml SCORM 1.2 + wrapper API SCORM) et le bouton "Exporter (Web/SCORM)". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9776fbc057
commit
f3b72d73c9
@@ -0,0 +1,102 @@
|
||||
"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir
|
||||
routes/publish/export_scorm.py) : à la différence de build_package.py
|
||||
(qui embarque Python + Flask, voir player_app.py), ce paquet est
|
||||
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. Rien de commun avec le zip .exe hors le sous-ensemble de
|
||||
static/ et _copy_characters (mêmes assets, mêmes règles de copie
|
||||
sélective) — réutilisés depuis build_package plutôt que dupliqués."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from flask import render_template
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .build_package import _copy_characters, _STATIC_ITEMS
|
||||
from .scorm_manifest import build_manifest_xml
|
||||
|
||||
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
|
||||
|
||||
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)
|
||||
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 _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)
|
||||
|
||||
staging_dir = tempfile.mkdtemp(prefix="forge_scorm_")
|
||||
try:
|
||||
_copy_static_assets(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,
|
||||
)
|
||||
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"]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user