Deuxième et dernier commit de la Phase A du plan "Fondations d'une
plateforme multi-éditeurs" (le premier, efa7d1d, posait le schéma et le
CRUD des objets de scène). Ce commit branche l'éditeur et le mode
jouable sur ces fondations, en réutilisant TEL QUEL tout ce qui est déjà
générique côté moteur de logique.
Éditeur (routes/scenes/, templates/scene_edit.html,
static/js/scenes/scene-editor.js) :
- routes/screens/screen_edit.py délègue à render_scene_edit() dès que
game_type == "jeu_2d" — même endpoint Flask "screen_edit" pour les
deux éditeurs, aucune route dupliquée.
- Nouveau template scene_edit.html : canevas à taille FIXE en pixels
(scene_width/scene_height) avec glisser-déposer/redimensionnement en
pixels (scene-editor.js, mirror pixel de tree-panels.js), galerie
personnages/décors. Les onglets "Blocs de logique"/"Timeline
d'animation"/"Événements" réutilisent tels quels flow-editor.js,
tabs-and-blocks.js et animation-timeline.js.
- flow-editor.js : nouveau flag FLOW_TARGETS_OBJECTS (false par défaut,
true dans scene_edit.html) qui fait écrire submitNodeForm() vers
trigger_object_id/target_object_id au lieu de trigger_element_id/
target_element_id, plus une branche "Modifier un objet de scène"
(position px, visibilité, orientation) et des gardes null partout
(le DOM de scene_edit.html n'a pas tous les champs de l'éditeur
document).
- blocks_view.py étend l'ensemble d'ids d'éléments concernés par un
bloc pour inclure aussi trigger_object_id/target_object_id.
Runtime jouable (templates/play.html, static/js/play/actions.js,
screens/payload/full_game_payload.py) :
- full_game_payload() construit les écrans d'un jeu jeu_2d à partir de
_scene_objects (render_scene_object) au lieu de _screen_elements, et
récupère les animations de personnage sur les objets de scène.
- play.html : #playFrame occupe tout le viewport pour une scène jeu_2d
(pas de ratio fluide) ; la scène se centre via .playScreen.playScene,
qui réutilise tel quel showScreen() (déjà indexé par id d'écran, pas
par forme DOM) — aucun fichier "scenes.js" séparé n'a été nécessaire.
- actions.js : jouer_animation_sprite retombe sur target_object_id si
target_element_id est absent ; nouvelle action modifier_objet_scene
avec applyObjectProperty() (positions en px, contrairement aux % de
applyElementProperty()).
Un bug de gabarit a été découvert et corrigé pendant la vérification :
le commentaire CSS de play.html contenait littéralement "{% for %}"
comme texte français, que Jinja interprétait comme une vraie balise et
faisait planter le rendu — reformulé. render_scene_object() n'était en
outre jamais appelé par la vue de l'éditeur (les objets de scène
s'affichaient sans image) — scene_edit_view.py attache maintenant
rendered_html à chaque objet avant de les passer au template.
Nouveaux tests (tests/test_scene_edit_view.py, 10 cas) : dispatch
document vs jeu_2d, rendu d'un personnage sélectionné, CRUD géométrie/
suppression d'objet, nœuds de flow ciblant un objet de scène (action et
condition de collision), payload et page /play pour un jeu jeu_2d,
présence des nouveaux helpers JS. Suite complète : 309 tests passent
(299 existants + 10 nouveaux, aucune régression). node --check et
node --test (13 tests JS) passent également.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
153 lines
7.9 KiB
HTML
153 lines
7.9 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="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
|
|
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='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;
|
|
{% if game.game_type == 'jeu_2d' %}
|
|
/* Fondations multi-éditeurs — une scène jeu_2d a une taille FIXE en
|
|
pixels (voir scene_width/scene_height, ensure_schema.py), pas un
|
|
ratio fluide : #playFrame remplit simplement tout le viewport, la
|
|
scène elle-même se centre dedans (voir .playScreen ci-dessous et
|
|
la boucle d'écrans jeu_2d plus bas). */
|
|
width:100vw; height:100vh;
|
|
{% else %}
|
|
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)));
|
|
{% endif %}
|
|
}
|
|
.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">
|
|
{% if game.game_type == 'jeu_2d' %}
|
|
<!-- Fondations multi-éditeurs — scène 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 de l'éditeur document) : juste le HTML tel quel. -->
|
|
{% for s in screens_data %}
|
|
<div class="playScreen playScene" data-screen-id="{{ s.id }}" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px;">
|
|
{% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %}
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
{% for s in screens_data %}
|
|
<div class="playScreen" data-screen-id="{{ s.id }}" 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>
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% 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;
|
|
var FORGE_PLAY_URLS = {
|
|
runtimePayload: {{ runtime_payload_url|tojson }},
|
|
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
|
|
flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }},
|
|
flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }},
|
|
};
|
|
</script>
|
|
<script src="{{ url_for('static', filename='js/play/screens.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/play/conditions.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/play/actions.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/play/triggers.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/play/bindings.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='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();
|
|
</script>
|
|
</body>
|
|
</html>
|