Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
222 lines
12 KiB
HTML
222 lines
12 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/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; }
|
|
.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/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" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px;">
|
|
<!-- .sceneWorld : le "monde" (voir static/style.css et
|
|
static/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 }}">
|
|
{% 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">
|
|
{% 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/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 }};
|
|
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/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/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/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('js/play/screens.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/conditions.js') }}"></script>
|
|
{% if offline_mode %}
|
|
<script src="{{ asset_url('js/play/offline/compute-operation.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/apply-actions.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/filter-repeater-rows.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/visibility-condition.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/render-jauge.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/render-overlay.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/render-special-widgets.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/render-repeater.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/render-element.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/offline/full-payload.js') }}"></script>
|
|
{% if scorm_api_wrapper_url %}<script src="{{ scorm_api_wrapper_url }}"></script>{% endif %}
|
|
<script src="{{ asset_url('js/play/offline/xapi-client.js') }}"></script>
|
|
{% endif %}
|
|
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/dialogue-box-controller.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
|
|
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
|
|
<script src="{{ asset_url('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>
|