Files
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

239 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title>
<link rel="icon" type="image/svg+xml" href="{{ asset_url('branding/forge-favicon.svg') }}">
<link rel="stylesheet" href="{{ asset_url('style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ asset_url('csrf_fetch.js') }}"></script>
<link rel="stylesheet" href="{{ asset_url('vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ asset_url('vendor/animate.min.css') }}"><style>
body{ margin:0; background:#000; }
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
choisi dans l'éditeur — voir preview_aspect). Positionner les éléments
en % de la fenêtre du NAVIGATEUR (dont la forme varie selon l'appareil
du joueur) les déformait : un carré dessiné en Portrait pouvait
s'afficher étiré/écrasé sur un écran de proportion différente. #playFrame
garde donc toujours la même proportion que l'écran en cours (mise à
jour dans showScreen ci-dessous), quelle que soit la fenêtre réelle —
avec des bandes noires (letterboxing) si besoin, comme un lecteur
vidéo — pour que les % restent fidèles à ce qui a été conçu. */
#playRoot{
position:relative; width:100vw; height:100vh; overflow:hidden; background:#000;
display:flex; align-items:center; justify-content:center;
}
#playFrame{
position:relative; overflow:hidden; background:#0b0d12;
width: min(100vw, calc(100vh * var(--ratio-w, 9) / var(--ratio-h, 16)));
height: min(100vh, calc(100vw * var(--ratio-h, 16) / var(--ratio-w, 9)));
}
/* Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : le
type d'écran ("document"/"jeu_2d") est désormais choisi PAR ÉCRAN, pas
figé pour toute la page au chargement — changer d'écran ne recharge
pas la page (voir showScreen() dans static/game/js/play/screens.js), donc
la taille de #playFrame doit pouvoir basculer EN COURS DE PARTIE.
.sceneMode (posée/enlevée par showScreen() selon screen.kind) donne à
une scène jeu_2d sa taille FIXE en pixels (scene_width/scene_height) —
elle-même se centre dedans (voir .playScreen.playScene ci-dessous) —
au lieu du ratio fluide par défaut ci-dessus. */
#playFrame.sceneMode{ width:100vw; height:100vh; }
.playScreen{ position:absolute; inset:0; display:none; }
.playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; }
/* "La notion de caméra disparaît" quand un quiz plein écran existe sur
cet écran (demande explicite, voir screens/payload/full_game_payload.py
::any_quiz_box_fullscreen) — le réglage "Caméra (px)" (scene_width/
scene_height, qui donne à .playScene ci-dessus sa taille ET son
`transform` de centrage) ne s'applique alors plus DU TOUT : cet écran
devient lui-même plein viewport, sans transform (qui casserait
position:fixed pour tout descendant, voir dialogue-box-controller.js
::forgeEscapeCameraForFullscreen — plus nécessaire ici puisque
.playScene n'a elle-même plus de transform dans ce cas). */
.playScreen.playScene.playScene--quizFullscreen{
position:fixed; inset:0; top:0; left:0; width:100vw; height:100vh; transform:none;
}
.playElement{
position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif;
}
.playElement.clickable{ cursor:pointer; }
.playElementInner{
width:100%; height:100%;
display:flex; align-items:center; justify-content:center;
}
.playElement input{ width:90%; height:70%; box-sizing:border-box; border:1px solid #2b3140; border-radius:4px; padding:0 8px; }
#emptyState{ color:var(--forge-text-muted, #8b95a7); font-family:system-ui,sans-serif; text-align:center; padding-top:40vh; }
/* 3.3 (Confort) — surbrillance générique dynamique : liseré clignotant,
posé/enlevé par une action "Modifier un élément → Surbrillance",
applicable à n'importe quel élément (pas seulement les boutons). */
@keyframes forgeHighlightPulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(255, 196, 0, 0.9); }
50% { box-shadow: 0 0 0 3px rgba(255, 196, 0, 0.15); }
}
.forgeHighlight{ animation: forgeHighlightPulse 1.1s ease-in-out infinite; }
/* RGAA/WCAG 2.3.3 (confort) : un utilisateur sensible aux animations
(prefers-reduced-motion, réglage système) ne doit jamais voir un
clignotement en boucle infinie — remplacé par un simple contour fixe. */
@media (prefers-reduced-motion: reduce) {
.forgeHighlight{ animation:none; box-shadow:0 0 0 3px rgba(255, 196, 0, 0.9); }
}
/* RGAA/WCAG 4.1.2 : texte réservé aux lecteurs d'écran, invisible à
l'écran mais jamais display:none (qui le retirerait aussi de l'arbre
d'accessibilité) — voir templates/game/play.html, le <h1> caché du titre. */
.srOnly{
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
}
/* 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
inerte, posé/enlevé par une action "Modifier un élément → Désactivé". */
.forgeDisabled{ opacity:0.45; filter:grayscale(60%); pointer-events:none; }
/* Phase 7 — personnage animé par sprites : retourne l'élément en miroir
plutôt que de nécessiter une 2e feuille de sprites "vue de dos". */
.forgeFlipped{ transform: scaleX(-1); }
</style>
</head>
<body>
<main id="playRoot">
<h1 class="srOnly">{{ game.name }}</h1>
<div id="playFrame">
{% for s in screens_data %}
<!-- Un seul moteur d'écran désormais (jeu_2d) : objets déjà entièrement
positionnés en pixels absolus par render_scene_object.py (voir
screens/scenes/) — juste le HTML tel quel. -->
<div class="playScreen playScene {{ 'playScene--quizFullscreen' if s.any_quiz_box_fullscreen else '' }}" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="{{ '' if s.any_quiz_box_fullscreen else 'width:' + s.scene_width|string + 'px; height:' + s.scene_height|string + 'px;' }}">
<!-- .sceneWorld : le "monde" (voir static/style.css et
static/game/js/play/personnage-controller.js) — taille par défaut 100%
(identique au viewport ci-dessus), agrandie/décalée en JS
uniquement si un objet kind="fond" plus grand que la scène est posé
(caméra qui suit le personnage). -->
<div class="sceneWorld" data-scene-width="{{ s.scene_width }}" data-scene-height="{{ s.scene_height }}">
{# NOSONAR S5247 - rendered_html vient de render_scene_object() (Python), qui echappe deja tout texte utilisateur via html.escape() #}
{% for obj in s.elements if obj.kind not in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
</div>
<!-- .sceneUI : widgets d'interface FIXES au viewport, jamais dans
.sceneWorld ci-dessus (voir static/style.css) — "💬 Boîte de
dialogue"/"❓ Boîte à quiz"/"🏆 Score" (screens/rendering/dialogue_box_style.py). -->
<div class="sceneUI">
{# NOSONAR S5247 - meme raison que ci-dessus #}
{% for obj in s.elements if obj.kind in ('dialogue_box', 'quiz_box', 'score_widget') %}{{ obj.rendered_html|safe }}{% endfor %}
</div>
</div>
{% endfor %}
{% if not screens_data %}
<div id="emptyState">Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.</div>
{% endif %}
</div>
</main>
<script>
// Données injectées par Jinja (côté serveur) — tout le reste du moteur
// vit maintenant dans des fichiers statiques sous static/game/js/play/ (voir
// plan de modularisation : un fichier = une responsabilité, aussi pour
// le JS), qui ne peuvent pas contenir de code Jinja puisqu'ils ne
// passent jamais par le moteur de templates. FORGE_PLAY_URLS porte donc
// les URLs de routes dont ces fichiers ont besoin (actions.js,
// bindings.js), construites ici une seule fois — passées par la route
// elle-même (runtime_payload_url/flow_node_run_data_url/
// flow_node_run_variable_url/flow_node_run_add_row_url) plutôt que
// construites ici avec des noms de endpoint fixes : ce même template
// sert aussi bien l'aperçu créateur (/game/<slug>/play,
// routes/play/game_play.py) que la partie publique par joueur
// (/jouer/<slug>, routes/public_play/), deux familles de routes
// distinctes (voir état par joueur, Phase 1).
let gameData = {{ payload_json|safe }}; // NOSONAR S5247 - json_for_script() echappe deja la balise fermante de script (voir db/json_for_script.py)
let screensData = gameData.screens;
{% if offline_mode %}
// Export Web/SCORM (voir publish/build_scorm_package.py) : paquet
// autonome sans serveur Forge Engine — actions.js/bindings.js basculent
// sur static/game/js/play/offline/*.js (ci-dessous) au lieu d'un fetch().
window.FORGE_OFFLINE = true;
var FORGE_PLAY_URLS = {};
// xAPI (voir publish/build_scorm_package.py::build_scorm_zip,
// static/game/js/play/offline/xapi-client.js) : bolt-on greffé sur le SCORM
// classique ci-dessous (scorm_api_wrapper_url), jamais lié à lui — un
// paquet sans réglage LRS renvoie `null` ici, xapi-client.js ne fait
// alors rien.
window.FORGE_XAPI_CONFIG = {{ xapi_config_json|safe }};
// Seuil de réussite (voir db/games/get_success_threshold.py) : % de
// bonnes réponses au quiz requis pour un statut de fin "reussi" plutôt
// que "echoue" (SCORM lesson_status ET xAPI, voir dialogue-box-controller.js).
window.FORGE_SUCCESS_THRESHOLD_PERCENT = {{ success_threshold_percent|tojson }};
// Score max possible du quiz (voir db/dialogue_lines.py::sum_question_rewards) : permet
// au SCORM 1.2 de fournir cmi.core.score.min/max en plus du score brut
// (voir static/game/js/play/offline/scorm-api.js), pour qu'un LMS calcule un
// vrai pourcentage au lieu d'afficher le score brut à tort étiqueté "%".
window.FORGE_MAX_QUIZ_SCORE = {{ max_quiz_score|tojson }};
{% else %}
var FORGE_PLAY_URLS = {
runtimePayload: {{ runtime_payload_url|tojson }},
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }},
flowNodeRunScore: {{ flow_node_run_score_url|tojson }},
flowNodeRunStatus: {{ flow_node_run_status_url|tojson }},
flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }},
};
{% endif %}
</script>
<script src="{{ asset_url('game/js/play/screens.js') }}"></script>
<script src="{{ asset_url('game/js/play/conditions.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/compute-operation.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/apply-actions.js') }}"></script>
{% if offline_mode %}
<script src="{{ asset_url('game/js/play/offline/filter-repeater-rows.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/visibility-condition.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/render-jauge.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/render-overlay.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/render-special-widgets.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/render-repeater.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/render-element.js') }}"></script>
<script src="{{ asset_url('game/js/play/offline/full-payload.js') }}"></script>
{% if scorm_api_wrapper_url %}<script src="{{ scorm_api_wrapper_url }}"></script>{% endif %}
<script src="{{ asset_url('game/js/play/offline/xapi-client.js') }}"></script>
{% endif %}
<script src="{{ asset_url('game/js/play/actions.js') }}"></script>
<script src="{{ asset_url('game/js/play/triggers.js') }}"></script>
<script src="{{ asset_url('game/js/play/personnage-controller.js') }}"></script>
<script src="{{ asset_url('game/js/play/quiz-box-templates.js') }}"></script>
<script src="{{ asset_url('game/js/play/dialogue-box-controller.js') }}"></script>
<script src="{{ asset_url('game/js/play/video-overlay.js') }}"></script>
<script src="{{ asset_url('game/js/play/collision-rules-controller.js') }}"></script>
<script src="{{ asset_url('game/js/play/screen-triggers-controller.js') }}"></script>
<script src="{{ asset_url('game/js/play/bindings.js') }}"></script>
<script src="{{ asset_url('game/js/play/flow-engine.js') }}"></script>
<script>
// L'écran affiché au démarrage doit être choisi AVANT le premier
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se
// fait sur l'écran courant (window.currentScreenId), qui doit donc déjà
// être connu au moment où on attache les gestionnaires de clic.
injectCustomKeyframes();
if (screensData.length) showScreen(screensData[0].id);
// [data-element-id] est posé par le moteur de rendu sur CHAQUE élément,
// qu'il soit directement sur l'écran ou imbriqué à l'intérieur d'un
// conteneur/répéteur — donc un bouton posé à l'intérieur d'un conteneur
// déclenche bien son action au clic, exactement comme un bouton posé
// directement sur l'écran.
bindClicks();
bindHoverTexts();
bindHoverTriggers();
// Un seul écouteur clavier pour tout le jeu (Phase 3) — showScreen()
// ci-dessus a déjà démarré les minuteurs de l'écran affiché au premier
// affichage (runScreenTimerTriggers, appelé depuis showScreen()).
bindKeyboardTriggers();
// Mouvement continu (touche maintenue/relâchée) — même principe, voir
// triggers.js. runScreenHeldKeyTriggers(), lui, est déjà appelé depuis
// showScreen() comme runScreenTimerTriggers().
bindHeldKeyTriggers();
// Le jeu exporté (SCORM) est souvent chargé dans une iframe par le LMS,
// qui ne reçoit pas le focus clavier automatiquement — sans focus, les
// événements keydown n'atteignent jamais window et le personnage ne
// bouge/anime jamais. On force le focus au chargement et on le
// reprend au premier clic dans le jeu.
window.focus();
document.body.tabIndex = -1;
document.body.addEventListener('click', function () { window.focus(); });
</script>
</body>
</html>