- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme des points des "❓ Question" ne peut plus dépasser recompense_score (routes/quests/quest_dialogues.py à l'enregistrement des dialogues, quest_update.py si on abaisse la récompense sous ce qui est déjà réparti) — message d'erreur explicite (400) dans les deux sens, affiché via alert() côté éditeur (quest-editor.js), jamais enregistré silencieusement dans un état incohérent. - Nombre de choix par question plafonné à 4 (au lieu de 8). - Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte de dialogue" — screens/rendering/dialogue_box_style.py, même panneau "🎨 Style") : - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une question se résout au clic sur un choix, pas de "Suivant"). - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible une fois posé (contrairement aux boîtes de dialogue/quiz, masquées par défaut). - Moteur d'exécution (static/js/play/dialogue-box-controller.js, refonte) : une conversation de quête alterne maintenant répliques (boîte de dialogue) et questions (boîte à quiz) selon le type de chaque ligne. Bonne réponse -> crédite le score (compteur runtime dédié, jamais lié au score du moteur document) et avance ; mauvaise réponse -> rien, la question reste affichée pour réessayer ; aucune boîte à quiz posée -> la question est ignorée plutôt que de bloquer la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait des questions, toutes répondues) fait passer la quête "terminee" (en mémoire seulement, comme le reste de ce système — voir accepter/ refuser une offre de quête) : le joueur peut alors quitter la scène normalement, ces widgets n'étant jamais des obstacles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
195 lines
11 KiB
HTML
195 lines
11 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; }
|
|
/* 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>
|
|
<div id="playRoot">
|
|
<div id="playFrame">
|
|
{% for s in screens_data %}
|
|
{% if s.kind == 'jeu_2d' %}
|
|
<!-- Fusion des moteurs — écran "jeu_2d" : objets déjà entièrement
|
|
positionnés en pixels absolus par render_scene_object.py (voir
|
|
screens/scenes/), pas de wrapper .playElement/elstyle (spécifiques
|
|
au %/DOM d'un écran "document") : 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>
|
|
{% else %}
|
|
<div class="playScreen" data-screen-id="{{ s.id }}" data-kind="document" data-aspect="{{ s.preview_aspect or '9/16' }}">
|
|
{% for el in s.elements if not el.parent_id %}
|
|
<div class="playElement" data-el-id="{{ el.id }}" data-anim-target="{{ el.id }}" style="{{ el|elstyle }}">
|
|
<div class="playElementInner" style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
{% 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>
|
|
</div>
|
|
|
|
<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 = {};
|
|
{% 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 %}
|
|
{% 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();
|
|
</script>
|
|
</body>
|
|
</html>
|