Ajoute la vraie route hébergée multi-joueurs qui manquait totalement
(voir le constat d'exploration : /game/<slug>/play est réservé au
créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) —
un visiteur anonyme peut maintenant jouer un jeu explicitement publié en
ligne, avec sa propre partie (variables/objets per_player posés dans le
commit précédent).
core/player_identity.py : identité visiteur via un cookie NON SIGNÉ
(forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de
partition, jamais un jeton d'autorisation.
routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu
créateur mais threadées avec le vrai player_id du cookie au lieu du
sentinel PLAYER_SHARED :
- GET /jouer/<slug> (game_play_public.py)
- GET /jouer/<slug>/runtime-payload
- POST /jouer/<slug>/flow/nodes/<id>/run-data
- POST /jouer/<slug>/flow/nodes/<id>/run-variable
Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu
n'est exposé publiquement que si le créateur l'a explicitement basculé
"Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la
table générique _meta, comme game_meta.py pour 'name').
core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS
— la garde générique de connexion les laisse passer sans session, mais
chaque vue vérifie quand même is_public_played elle-même (défense en
profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py)
n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask,
qui existe pour n'importe quel visiteur (connecté ou non).
templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit
plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload',
...)) mais reçoit des URLs déjà résolues par la route elle-même
(runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url)
— nécessaire puisque ce même template sert maintenant DEUX familles de
routes (aperçu créateur ET partie publique), chacune avec ses propres
noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ
comportement) et game_play_public.py passent chacun ses propres URLs.
templates/base.html : bascule "🌐 Publier en ligne" dans la barre de
navigation du jeu, à côté de "📦 Publier" (export .zip) — deux
fonctionnalités distinctes. db/games/game_meta.py expose maintenant
is_public_played, disponible partout où `game` est dans le contexte.
Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un
bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux
cookies forge_player_id différents — qui obtiennent des valeurs de
variable indépendantes, et un qui verrouille que l'aperçu créateur reste
inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu
(aperçu créateur et partie publique).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
6.2 KiB
HTML
122 lines
6.2 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;
|
|
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)));
|
|
}
|
|
.playScreen{ position:absolute; inset:0; display: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; }
|
|
/* 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="playRoot">
|
|
<div id="playFrame">
|
|
{% 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 %}
|
|
{% 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) 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 }},
|
|
};
|
|
</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();
|
|
</script>
|
|
</body>
|
|
</html>
|