Files
Forge-Engine/templates/play.html
T
williamandClaude Sonnet 5 f2a73f2c73 Refonte design system — Phase A : Bulma auto-hébergé compilé via Sass
Met en place les fondations du template Bulma décrit dans
regles/FORGE_ENGINE_TEMPLATE_BULMA.md : palette orange/ambre Forge
(--accent:#ff5f2e/--accent-2:#ffb020), typographie, rayons — appliqués
en recompilant Bulma lui-même plutôt qu'en le surchargeant après coup en
CSS, pour recolorer automatiquement ses composants internes (tags,
notifications, dropdowns...) sans avoir à les surcharger un par un.

- Bulma 0.9.4 vendoré en local (styles/bulma/, source Sass classique
  $variable + @import) — PAS 1.0.2 (la version jusqu'ici en CDN) : 1.0.2
  utilise le nouveau système de modules @use/@forward, que libsass (choisi
  pour rester 100% Python, sans Node/npm) ne sait pas compiler (testé :
  il ignore silencieusement le @use au lieu de le traiter). 0.9.4 est la
  dernière version compatible avec libsass et couvre à l'identique tous
  les composants utilisés ici (boutons, tableaux, onglets, modales,
  formulaires, navbar).
- requirements.txt : +libsass (pip pur, aucun binaire/Node.js).
- styles/forge-theme.scss (nouveau, point d'entrée) : variables Sass
  Bulma ($primary, $radius...) posées avant l'import, tokens Forge exposés
  en :root (--forge-bg, --accent, --gradient, --status-*...) avec des
  alias vers les noms de variables déjà utilisés par tout le CSS custom
  existant (--bg/--panel/--border/--text/--danger...) — pas besoin de
  renommer les ~600 lignes de règles déjà écrites, seules leurs VALEURS
  changent.
- styles/forge-custom.scss : ancien static/style.css, structurellement
  inchangé — seuls les hex/rgba en dur qui échappaient aux variables
  (ancien accent bleu #5b8cff, danger #e2685f, couleurs de types de
  nœuds du graphe de logique, fond du QR code recovery...) sont
  remplacés par les tokens de la charte.
- build_css.py (nouveau) : compile styles/forge-theme.scss en
  static/style.css via libsass — un seul fichier, un seul <link>
  inchangé dans les templates, à relancer manuellement après toute
  modification sous styles/.
- base.html/play.html : suppression du <link> CDN Bulma (auto-hébergé
  désormais), ajout d'un favicon (absent jusqu'ici) et du vrai logo Forge
  dans la navbar (assets/*.svg copiés dans static/branding/, seul dossier
  réellement servi par Flask).

197 tests toujours verts (aucune assertion sur des valeurs CSS).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:01:32 +02:00

1016 lines
53 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="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/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:#8b93a7; 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>
let gameData = {{ payload_json|safe }};
let screensData = gameData.screens;
// Élément à onglets (voir render_onglets.py) : bascule le panneau visible
// sans aller-retour serveur — même fonction que dans screen_edit.html,
// pour un comportement identique éditeur/jeu.
function forgeShowTab(btn) {
var groupId = btn.dataset.tabBtn;
var targetId = btn.dataset.tabTarget;
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
b.classList.toggle('is-active', b === btn);
});
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
p.style.display = (p.id === targetId) ? '' : 'none';
});
}
function showScreen(id) {
// Déjà l'écran affiché : rien à refaire. Sans ce garde-fou, l'action
// "Ouvrir la ligne cliquée" (voir plus bas) — pensée pour révéler un
// détail SUR LE MÊME écran qu'un Répéteur, en la combinant avec une
// action "Modifier un élément → Visibilité" sur le même clic — rejouait
// à chaque clic sur une ligne les déclencheurs "À l'affichage de
// l'écran" et relançait la timeline d'animation depuis le début, ce qui
// donnait l'impression trompeuse d'un rechargement de page (éléments
// qui rejouent leur animation d'entrée, visibilité qu'un déclencheur
// "affichage" repasse à son état initial en écrasant l'action "Rendre
// visible" du même clic...). Aller vers un écran DIFFÉRENT continue de
// tout rejouer normalement, comme un vrai premier affichage.
if (id === window.currentScreenId) return;
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
if (el) {
el.style.display = 'block';
window.currentScreenId = id;
applyScreenAspect(el.dataset.aspect || '9/16');
}
runScreenShowTriggers(id);
runAnimationTimeline(id);
}
// Voir le commentaire sur #playFrame dans le <style> ci-dessus : la zone
// de jeu garde toujours la proportion de l'écran affiché, quelle que soit
// la forme réelle de la fenêtre du joueur.
function applyScreenAspect(aspect) {
var parts = String(aspect).split('/');
var w = parseFloat(parts[0]) || 9, h = parseFloat(parts[1]) || 16;
var frame = document.getElementById('playFrame');
if (frame) {
frame.style.setProperty('--ratio-w', w);
frame.style.setProperty('--ratio-h', h);
}
}
// ---------- Timeline d'animation (menu "Ajouter un élément" → panneau
// rétractable sous la Logique de la scène) : chaque clip démarre à un
// instant précis (start_time, en secondes) après l'affichage de l'écran —
// exactement comme une timeline Unity jouée au chargement d'une scène.
// Deux types de clip : "animate_css" pose une classe de la bibliothèque
// Animate.css (voir animate_css_catalog.py), "custom" joue des
// images-clés propres au jeu (voir injectCustomKeyframes ci-dessous). ----------
var pendingAnimationTimers = [];
function injectCustomKeyframes() {
// Construit UNE FOIS toutes les @keyframes "personnalisées" de tous les
// écrans (les données sont déjà toutes chargées dans gameData) et les
// pose dans une unique balise <style>, plutôt que d'en injecter une par
// clip à chaque lecture — un nom de règle unique par clip (customAnim_id)
// évite toute collision entre deux clips personnalisés différents.
var rules = [];
Object.keys(gameData.animations || {}).forEach(function(screenId) {
(gameData.animations[screenId] || []).forEach(function(clip) {
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
var keyframes;
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
var steps = keyframes.map(function(k) {
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
return k.percent + '% { ' + props + ' }';
}).join(' ');
rules.push('@keyframes customAnim_' + clip.id + ' { ' + steps + ' }');
});
});
var styleTag = document.getElementById('customAnimStyles');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'customAnimStyles';
document.head.appendChild(styleTag);
}
styleTag.textContent = rules.join('\n');
}
function applyAnimationClip(clip) {
// querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un
// écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres
// enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce
// modèle (rechargé en direct depuis le même écran-modèle, voir
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
if (!targets.length) return;
var duration = clip.duration || 1, delay = clip.delay || 0;
var iterationCount = clip.iteration_count || '1';
var infinite = iterationCount === 'infinite';
targets.forEach(function(target) {
if (clip.kind === 'custom') {
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
' ' + delay + 's ' + iterationCount;
if (!infinite) {
target.addEventListener('animationend', function handler() {
target.style.animation = '';
target.removeEventListener('animationend', handler);
});
}
} else {
var animClass = 'animate__' + clip.animate_name;
target.style.setProperty('--animate-duration', duration + 's');
target.style.setProperty('--animate-delay', delay + 's');
target.style.animationIterationCount = iterationCount;
target.classList.add('animate__animated', animClass);
if (!infinite) {
target.addEventListener('animationend', function handler() {
target.classList.remove('animate__animated', animClass);
target.removeEventListener('animationend', handler);
});
}
}
});
}
// Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE
// utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet
// écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser
// un autre modèle), avec une garde anti-boucle et un dédoublonnage par
// écran (un même modèle posé plusieurs fois ne doit pas répéter ses
// clips). Sans ça, une animation posée directement dans l'éditeur d'un
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
function collectAnimationClips(screenId) {
var visited = {};
var clips = [];
function visit(sid) {
if (visited[sid]) return;
visited[sid] = true;
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
if (!screenData) return;
screenData.elements.forEach(function(el) {
if (!el.element_type_id) return;
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
if (templateScreenId != null) visit(templateScreenId);
});
}
visit(screenId);
return clips;
}
function runAnimationTimeline(screenId) {
// Repart de zéro à chaque affichage de cet écran (comme
// runScreenShowTriggers) : on annule d'abord tout clip encore en
// attente d'un affichage précédent, pour ne jamais empiler des
// déclenchements en double si le joueur revient plusieurs fois de
// suite sur le même écran avant la fin de sa timeline.
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
pendingAnimationTimers = [];
var clips = collectAnimationClips(screenId);
clips.forEach(function(clip) {
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
pendingAnimationTimers.push(t);
});
}
// Déclenche tous les nœuds "À l'affichage de l'écran" de CET écran — pas
// besoin d'avoir cliqué sur quoi que ce soit : ça couvre le premier
// affichage, un retour en arrière, un changement d'écran (ecran_suivant/
// ecran_precedent/aller_a/ouvrir_ligne appellent tous showScreen). C'est
// ce qui permet à une condition ("SI outil débloqué = faux") + une action
// "Modifier un élément → Visibilité" de refléter l'état de la partie sans
// attendre un clic (voir 1.2 dans la doc de cadrage Forge Engine).
function runScreenShowTriggers(screenId) {
const flow = gameData.flows[String(screenId)];
if (!flow) return;
flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'affichage'; })
.forEach(function(n){ runFlowFrom(n.id); });
}
function screenIndexById(id) {
return screensData.findIndex(s => s.id === id);
}
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
// le nœud Déclencheur qui lui est rattaché — c'est le point d'entrée du
// graphe à exécuter. On cherche dans TOUS les écrans (pas seulement
// l'écran courant) : bindClicks() attache les gestionnaires une seule
// fois, sur tous les éléments de tous les écrans (déjà tous présents dans
// le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant
// d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible.
// Cette recherche globale couvre aussi les écrans-MODÈLES (un élément de
// jeu réutilisable, ex. "mail content", posé sur une scène) : un
// déclencheur "Au clic"/"Au survol" posé dans l'éditeur du modèle, sur un
// de SES PROPRES enfants, est donc retrouvé même quand ce modèle est
// utilisé ailleurs — d'où le besoin de renvoyer aussi l'écran d'origine du
// nœud (screenIdKey), pas seulement le nœud : runFlowFrom() doit exécuter
// le graphe dans CET écran-là (celui du modèle), jamais dans celui
// réellement affiché, sans quoi le nœud trouvé n'existerait pas dans le
// graphe utilisé et l'action ne se déclencherait jamais.
function findTriggerNode(elementId, event) {
for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey];
const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event);
if (found) return { node: found, screenId: screenIdKey };
}
return null;
}
// Ré-attache les gestionnaires de clic sur chaque élément — appelé au
// chargement, puis à nouveau après un rafraîchissement des données (le
// DOM d'un écran est reconstruit après une action "Modifier une donnée",
// les nœuds précédents et leurs écouteurs disparaissent avec).
// 3.1 (Confort) — interactions au survol : échange le texte affiché
// contre data-hover-text pendant que la souris survole l'élément, puis le
// restaure au départ de la souris. On ignore volontairement les éléments
// qui ont des enfants (ex. un conteneur) pour ne jamais écraser une mise
// en page imbriquée avec du texte brut — cette fonctionnalité vise les
// éléments de texte simples (nom, lien...), pas les conteneurs.
function bindHoverTexts() {
document.querySelectorAll('[data-hover-text]').forEach(function(el) {
if (el.dataset.hoverBound) return;
if (el.children.length > 0) return;
el.dataset.hoverBound = '1';
var original = el.textContent;
el.addEventListener('mouseenter', function(){ el.textContent = el.dataset.hoverText; });
el.addEventListener('mouseleave', function(){ el.textContent = original; });
});
}
function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
const elementId = parseInt(elDiv.dataset.elementId, 10);
const trigger = findTriggerNode(elementId, 'clic');
if (trigger) {
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
// après chaque changement de donnée, mais un élément qui n'est ni un
// Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement
// à l'autre (seul son innerHTML change pour ceux-là, voir
// refreshRuntimeData()) — sans ce garde-fou, addEventListener('click')
// s'empilerait à chaque rafraîchissement et un seul clic sur un
// bouton "+10 points" finirait par déclencher l'action 2, 3, 10 fois
// d'affilée (d'où les sauts de +100/-20/0 constatés au lieu de ±10).
if (elDiv.dataset.clickBound) return;
elDiv.dataset.clickBound = '1';
elDiv.style.cursor = 'pointer';
elDiv.addEventListener('click', (ev) => {
ev.stopPropagation();
// Si l'élément cliqué (ou un de ses parents, jusqu'au Répéteur
// qui reçoit le gestionnaire) est en fait une LIGNE de Répéteur,
// on retrouve son vrai id de donnée (data-row-id, posé par
// render_repeater.py) — c'est ce qui permet à l'action "Ouvrir la
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
// alors même que le déclencheur, lui, vise le Répéteur entier.
const rowEl = ev.target.closest('.repeaterItem');
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
runFlowFrom(trigger.node.id, trigger.screenId);
});
}
});
}
// Déclencheurs "Au survol" / "Fin du survol" — même principe que
// bindClicks() (garde-fou anti-doublon compris), mais sur mouseenter/
// mouseleave plutôt que click, et les deux événements cherchés
// séparément puisque ce sont deux déclencheurs distincts et indépendants
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
function bindHoverTriggers() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
if (elDiv.dataset.hoverTriggerBound) return;
const elementId = parseInt(elDiv.dataset.elementId, 10);
const enterTrigger = findTriggerNode(elementId, 'survol');
const leaveTrigger = findTriggerNode(elementId, 'fin_survol');
if (!enterTrigger && !leaveTrigger) return;
elDiv.dataset.hoverTriggerBound = '1';
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId));
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId));
});
}
// Résout, sur l'écran couramment affiché, les placeholders "champ entre
// doubles accolades" restés tels quels dans le HTML statique (hors
// Répéteur, donc jamais résolus au moment du rendu serveur) avec les
// valeurs de la DERNIÈRE ligne ouverte via l'action "Ouvrir la ligne
// cliquée" (window.currentOpenRow). On part à chaque fois du HTML
// D'ORIGINE (el.rendered_html, jamais modifié) et pas du DOM déjà
// affiché, sinon rouvrir un mail différent laisserait les valeurs du mail
// précédent là où le nouveau mail n'a pas ce champ.
//
// Note : on construit les accolades doubles caractère par caractère
// (PLACEHOLDER_OPEN/CLOSE) plutôt que de les écrire en dur dans ce
// fichier — {{ et }} adjacents seraient interprétés par Jinja (le moteur
// de templates serveur) comme DU JINJA, pas comme du JavaScript littéral.
var PLACEHOLDER_OPEN = '{' + '{';
var PLACEHOLDER_CLOSE = '}' + '}';
function applyOpenRowBindings() {
if (!window.currentOpenRow) return;
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
const row = rows.find(r => r.id === window.currentOpenRow.rowId);
const screenData = screensData.find(s => s.id === window.currentScreenId);
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
if (!row || !screenData || !screenDiv) return;
// Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les
// éléments qui portent vraiment un {{champ}} non résolu — et non plus
// seulement les éléments de PREMIER NIVEAU de l'écran. Avant ce
// correctif, un panneau de détail niché à 2-3 niveaux (ex. "mail
// content" posé dans un conteneur, lui-même dans un autre) forçait à
// regénérer TOUT son ancêtre de premier niveau — potentiellement
// l'écran entier (jauges, onglets, Répéteur compris) s'il n'y avait
// qu'un seul gros conteneur racine — d'où l'impression trompeuse d'un
// rechargement de page (flash à vide) à chaque clic sur une ligne.
//
// Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que
// les plus SPÉCIFIQUES (les plus profonds) : si un élément ET l'un de
// ses DESCENDANTS sont tous deux candidats, seul le descendant est
// régénéré (rendered_html d'un conteneur inclut TOUJOURS celui de ses
// descendants, donc un conteneur "candidat" ne l'est souvent que parce
// qu'un descendant à lui l'est — le garder en plus regénérerait
// inutilement tout son sous-arbre, à l'exact opposé du but recherché).
// ATTENTION : la version précédente de ce filtre vérifiait l'inverse
// (exclure un élément si un ANCÊTRE est candidat) — comme un ancêtre
// est presque toujours "candidat" dès qu'un descendant l'est (son HTML
// le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine
// de l'écran, annulant complètement l'effet recherché par ce correctif.
const byId = {};
screenData.elements.forEach(function(e){ byId[e.id] = e; });
function hasUnresolvedPlaceholder(e) {
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
}
function isDescendantOf(e, ancestorId) {
let p = e.parent_id ? byId[e.parent_id] : null;
while (p) {
if (p.id === ancestorId) return true;
p = p.parent_id ? byId[p.parent_id] : null;
}
return false;
}
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
const targets = candidates.filter(function(e) {
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
});
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
// mais posé à la main puisqu'il n'y a pas de vrai DOM virtuel ici :
// - dans un nœud TEXTE : {{champ}} est coupé en 3 (texte avant, un
// <span data-bind-field="champ"> qui portera la valeur, texte
// après) LA PREMIÈRE FOIS SEULEMENT ; les ouvertures suivantes se
// contentent de changer le textContent de ce span.
// - dans un ATTRIBUT (ex. href d'un lien "{{link_real_url}}") : le
// gabarit d'origine est mémorisé sur un data-bind-attr-<nom> lors du
// premier passage, pour pouvoir recalculer et réécrire l'attribut à
// chaque fois sans jamais avoir besoin de reconstruire le nœud.
// Avant ce correctif, tout le sous-arbre ciblé était détruit et recréé
// depuis zéro à chaque clic sur une ligne — ce qui causait un flash
// visuel (perte de tout état transitoire posé par "Modifier un
// élément", donnant l'impression trompeuse d'un rechargement de page).
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
// rien à sauvegarder ni à restaurer.
const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g');
function resolveTextNodes(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const toSplit = [];
let node;
while ((node = walker.nextNode())) {
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
}
toSplit.forEach(function(textNode) {
const raw = textNode.nodeValue;
placeholderRe.lastIndex = 0;
const frag = document.createDocumentFragment();
let lastIndex = 0, match;
while ((match = placeholderRe.exec(raw))) {
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
const span = document.createElement('span');
span.dataset.bindField = match[1];
span.textContent = row[match[1]] == null ? '' : String(row[match[1]]);
frag.appendChild(span);
lastIndex = match.index + match[0].length;
}
if (lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex)));
textNode.replaceWith(frag);
});
}
function updateTextSlots(root) {
root.querySelectorAll('[data-bind-field]').forEach(function(slot) {
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
if (slot.textContent !== value) slot.textContent = value;
});
}
function resolveAttributes(root) {
[root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) {
Array.from(node.attributes).forEach(function(attr) {
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
const bindKey = 'data-bind-attr-' + attr.name;
const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
if (template === null) return;
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
placeholderRe.lastIndex = 0;
const resolved = template.replace(placeholderRe, function(_, fieldName) {
return row[fieldName] == null ? '' : String(row[fieldName]);
});
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
});
});
}
targets.forEach(function(el) {
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
if (!target) return;
if (target.querySelector('[data-bind-field]')) {
updateTextSlots(target);
} else {
resolveTextNodes(target);
}
resolveAttributes(target);
});
}
// Recharge l'état du jeu depuis le serveur (après une action qui a
// modifié une donnée d'objet) pour refléter la nouvelle valeur — utile
// pour un Répéteur de données, qui doit réafficher ses lignes à jour.
// Important : on ne touche QU'AUX éléments qui contiennent réellement un
// répéteur (repérable par la classe "repeaterItem" dans leur HTML rendu).
// Reconstruire TOUT l'écran à chaque rafraîchissement effacerait les
// changements purement visuels faits en même temps par une action
// "Modifier un élément" (ex: masquer une boîte) déclenchée par le même
// clic, puisque ces changements ne sont jamais enregistrés côté serveur.
function refreshRuntimeData() {
return fetch("{{ url_for('runtime_payload', slug=game.slug) }}")
.then(r => r.json())
.then(newData => {
gameData = newData;
screensData = gameData.screens;
const screenData = screensData.find(s => s.id === window.currentScreenId);
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
// autres réattachements juste après ne doivent JAMAIS dépendre de
// cette condition : un retour anticipé ici a déjà laissé, dans la
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
// de clic pour le reste de la partie (plus aucune ligne ne
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
// risque à rappeler même quand rien n'a changé.
if (screenData && screenDiv) {
// try/catch : une erreur inattendue pendant la régénération (ex.
// structure de données imprévue) ne doit jamais empêcher les
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
// tout autre élément cliquable) reste silencieusement inerte pour
// le reste de la partie, sans le moindre message d'erreur visible.
try {
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
// à une donnée), "visibilityGated" (condition de visibilité) et
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
// render_element_html.py) sont les types d'éléments dont le
// contenu/l'affichage dépend de la donnée elle-même plutôt que
// d'une action "Modifier un élément" posée à la main dans le
// graphe — eux seuls doivent être régénérés après un changement
// de donnée ou de variable.
//
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
// (pas seulement le premier niveau de l'écran), via leur
// data-element-id — disponible sur chaque élément rendu, pas
// seulement les éléments de premier niveau. Avant ce correctif,
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
// et se retrouvait régénéré en entier à sa place — potentiellement
// l'écran entier (jauges, onglets... compris) s'il n'y avait
// qu'un seul gros conteneur racine.
//
// Parmi les éléments marqués, on ne garde que les plus
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
// son rendered_html contient celui de ses descendants — le
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
// (Une version antérieure de ce filtre vérifiait l'inverse —
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
// gardait en pratique que l'ancêtre racine de l'écran et
// annulait complètement l'effet recherché.)
const byId = {};
screenData.elements.forEach(function(e){ byId[e.id] = e; });
function hasMarker(e) {
return !!(e.rendered_html && (
e.rendered_html.indexOf('repeaterItem') !== -1 ||
e.rendered_html.indexOf('jaugeBar') !== -1 ||
e.rendered_html.indexOf('visibilityGated') !== -1 ||
e.rendered_html.indexOf('dataBound') !== -1
));
}
function isDescendantOf(e, ancestorId) {
let p = e.parent_id ? byId[e.parent_id] : null;
while (p) {
if (p.id === ancestorId) return true;
p = p.parent_id ? byId[p.parent_id] : null;
}
return false;
}
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
// HTML juste après la balise concernée (voir _mark() dans
// render_element_html.py) — jamais comme une classe CSS, à la
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
// comportement déjà correct pour ces deux-là). Repérer ce
// commentaire permet de retrouver, à l'intérieur d'un fragment
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
// est niché dans un exemplaire d'élément de jeu réutilisable
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
// dialogue), où lui seul porte l'attribut data-element-id qui
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
// de premier niveau existant (l'exemplaire) que parce que son
// HTML est imbriqué dans le sien.
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
function findCommentMarkedDescendants(root) {
const found = [];
root.querySelectorAll('[data-element-id]').forEach(function(node) {
const sib = node.nextSibling;
if (sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1) {
found.push(node);
}
});
return found;
}
const markedCandidates = screenData.elements.filter(hasMarker);
markedCandidates.filter(function(e) {
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
}).forEach(function(el) {
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
if (!target) return;
const container = document.createElement('div');
container.innerHTML = el.rendered_html;
const newNode = container.firstElementChild;
if (!newNode) return;
const innerMarked = findCommentMarkedDescendants(newNode);
if (innerMarked.length) {
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
// Texte "Donnée liée" posé dans un élément de jeu
// réutilisable) : on ne patche QUE ceux-là, en préservant
// tout le reste — une boîte de dialogue ne doit ni
// disparaître ni réapparaître juste parce qu'un texte à
// l'intérieur a changé.
innerMarked.forEach(function(innerNode) {
const innerId = innerNode.getAttribute('data-element-id');
const liveInner = target.querySelector('[data-element-id="' + innerId + '"]');
if (liveInner) liveInner.replaceWith(innerNode);
});
} else {
target.replaceWith(newNode);
}
});
applyOpenRowBindings();
} catch (err) {
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
}
}
bindClicks();
bindHoverTexts();
bindHoverTriggers();
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
// tout de suite, sans attendre un aller-retour d'écran.
runScreenShowTriggers(window.currentScreenId);
});
}
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
// pilotable par une action "Modifier un élément".
var ELEMENT_PROPERTY_CSS = {
bg_color: { css: 'backgroundColor', suffix: '' },
border_color: { css: 'borderColor', suffix: '' },
width: { css: 'width', suffix: '%' },
height: { css: 'height', suffix: '%' },
};
function applyElementProperty(targetEl, property, value) {
if (property === 'contenu') {
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
// binding vers un champ d'objet ici (contrairement à
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
// pour les autres réglages ci-dessous, cible n'importe quel élément —
// pointer ceci vers un conteneur remplacerait tout son contenu
// imbriqué, à éviter.
targetEl.textContent = value == null ? '' : value;
return;
}
if (property === 'surbrillance') {
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
// clignote (voir la classe .forgeHighlight et son animation dans le
// <style> ci-dessus), applicable à N'IMPORTE QUEL élément sans poser
// de bordure colorée togglée à la main — utile pour un mentor qui
// attire l'attention du joueur vers l'élément à toucher ensuite.
if (value === 'toggle') {
targetEl.classList.toggle('forgeHighlight');
} else if (value === 'off') {
targetEl.classList.remove('forgeHighlight');
} else {
targetEl.classList.add('forgeHighlight');
}
return;
}
if (property === 'desactive') {
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
// de "invisible" (l'élément reste visible, mais grisé et inerte —
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
if (value === 'toggle') {
targetEl.classList.toggle('forgeDisabled');
} else if (value === 'off') {
targetEl.classList.remove('forgeDisabled');
} else {
targetEl.classList.add('forgeDisabled');
}
return;
}
if (property === 'visibilite') {
if (value === 'toggle') {
const hidden = targetEl.style.display === 'none';
targetEl.style.display = hidden ? '' : 'none';
targetEl.style.visibility = '';
} else if (value === 'masque') {
targetEl.style.display = 'none';
} else if (value === 'invisible') {
targetEl.style.display = '';
targetEl.style.visibility = 'hidden';
} else {
targetEl.style.display = '';
targetEl.style.visibility = '';
}
return;
}
const spec = ELEMENT_PROPERTY_CSS[property];
if (!spec || value === undefined || value === null) return;
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
// 1re valeur si l'écran est rechargé.
if (typeof value === 'string' && value.indexOf('||') !== -1) {
const parts = value.split('||');
const key = 'toggle' + property;
const atSecond = targetEl.dataset[key] === '1';
const next = atSecond ? parts[0] : parts[1];
targetEl.style[spec.css] = next + spec.suffix;
targetEl.dataset[key] = atSecond ? '0' : '1';
return;
}
targetEl.style[spec.css] = value + spec.suffix;
}
function runActionNode(node) {
if (node.action_type === 'ecran_suivant') {
const idx = screenIndexById(window.currentScreenId);
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
return Promise.resolve();
} else if (node.action_type === 'ecran_precedent') {
const idx = screenIndexById(window.currentScreenId);
if (idx > 0) showScreen(screensData[idx - 1].id);
return Promise.resolve();
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
showScreen(node.target_screen_id);
return Promise.resolve();
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
// action (voir bindClicks) puis affiche l'écran de détail avec ses
// placeholders "champ" résolus contre cette ligne précise.
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
}
showScreen(node.target_screen_id);
applyOpenRowBindings();
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
// le même conteneur — applyOpenRowBindings() vient de régénérer son
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
// lignes muettes dès le clic suivant, sans la moindre erreur.
bindClicks();
bindHoverTexts();
bindHoverTriggers();
return Promise.resolve();
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
// y compris "aucun parent" = les autres éléments de premier niveau de
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
// frère. screenData.elements contient TOUS les éléments de l'écran
// (imbriqués compris, voir list_elements côté serveur), donc ça
// fonctionne aussi bien pour des onglets imbriqués dans un même
// conteneur que pour des "vues" posées directement sur l'écran.
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
const allElements = screenData ? screenData.elements : [];
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
if (target) {
const parentKey = target.parent_id || null;
allElements
.filter(function(e){ return (e.parent_id || null) === parentKey; })
.forEach(function(e) {
const el = document.querySelector('[data-element-id="' + e.id + '"]');
if (!el) return;
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
else { el.style.display = 'none'; }
});
}
return Promise.resolve();
} else if (node.action_type === 'attendre') {
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
// pendant N secondes (node.data_value) avant de continuer sur le(s)
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
// quelques secondes après l'affichage de l'écran, sans action du
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
const seconds = parseFloat(node.data_value);
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
return new Promise(function(resolve) { setTimeout(resolve, ms); });
} else if (node.action_type === 'modifier_donnee') {
const runDataUrl = "{{ url_for('flow_node_run_data', slug=game.slug, node_id=999999) }}".replace('999999', node.id);
// clicked_row_id : utilisé côté serveur SEULEMENT si ce nœud vise la
// ligne cliquée (CLICKED_ROW_ID, voir flow_node_run_data.py) — sans
// effet sinon, donc toujours transmis sans condition ici.
return fetch(runDataUrl, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId })
})
.then(r => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'modifier_variable') {
const runVarUrl = "{{ url_for('flow_node_run_variable', slug=game.slug, node_id=999999) }}".replace('999999', node.id);
return fetch(runVarUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
}
// 'rien' : aucune action volontairement.
return Promise.resolve();
}
// Lit la valeur actuelle d'un champ d'objet dans l'instantané de données
// du jeu (gameData.data), pour l'évaluation d'une condition.
function readFieldValue(definitionId, rowId, fieldName) {
// CLICKED_ROW_ID (-1, voir flow_node_run_data.py) : "la ligne de
// Répéteur sur laquelle on vient de cliqué" — jamais connue à l'avance
// dans l'éditeur (choisie ici via "🖱️ Ligne cliquée (Répéteur)"),
// résolue seulement au moment de l'évaluation via le dernier clic
// capturé (voir bindClicks() plus haut).
if (rowId === -1) rowId = window.lastClickedRowId;
const rows = gameData.data[String(definitionId)] || [];
const row = rows.find(r => r.id === rowId);
return row ? row[fieldName] : undefined;
}
function compareValues(actual, operator, expected, fieldType) {
if (fieldType === 'booleen') {
const a = actual ? 1 : 0;
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
// partout ailleurs pour un champ booléen — une valeur de comparaison
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
// seulement "1"/"true" (et insensible à la casse, aligné avec
// _compare() côté Python, voir filter_repeater_rows.py).
const expectedStr = String(expected).trim().toLowerCase();
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
return operator === 'different' ? a !== e : a === e;
}
const an = parseFloat(actual), en = parseFloat(expected);
const numeric = !isNaN(an) && !isNaN(en);
if (numeric) {
switch (operator) {
case 'egal': return an === en;
case 'different': return an !== en;
case 'superieur': return an > en;
case 'inferieur': return an < en;
case 'superieur_egal': return an >= en;
case 'inferieur_egal': return an <= en;
}
}
const as = (actual === undefined || actual === null) ? '' : String(actual);
const es = (expected === undefined || expected === null) ? '' : String(expected);
switch (operator) {
case 'egal': return as === es;
case 'different': return as !== es;
case 'superieur': return as > es;
case 'inferieur': return as < es;
case 'superieur_egal': return as >= es;
case 'inferieur_egal': return as <= es;
default: return false;
}
}
// Lit la valeur ACTUELLE d'une variable globale (gameData.variables,
// exposé par full_game_payload.py, tenu à jour par refreshRuntimeData()
// après toute action qui en modifie une), pour l'évaluation d'une
// condition — équivalent, côté client, de _resolve_filter_value côté
// serveur (screens/rendering/filter_repeater_rows.py), mais SANS la
// syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais
// nécessaire ici : le nom de la variable est choisi dans un menu
// déroulant, voir screen_edit.html).
function readVariableValue(name) {
const v = (gameData.variables || {})[name];
return v ? v.value : undefined;
}
// Navigue dans une valeur JSON (variable de type "objet"/"tableau")
// selon un chemin ".champ"/"[index]" chaînable — équivalent JS de
// _resolve_variable_path (même fichier Python que ci-dessus). Chemin
// vide -> valeur brute inchangée (le cas normal pour une variable
// scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond
// à rien -> null, comme côté serveur.
function resolveVariablePath(rawValue, path) {
if (!path) return rawValue;
let current;
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
let m;
while ((m = segmentRe.exec(path)) !== null) {
if (current === null || current === undefined) return null;
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
}
return current === undefined ? null : current;
}
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
//
// Chaque clause peut tester soit un champ d'objet (source absente/"objet",
// comportement historique), soit une VARIABLE GLOBALE (source
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
// cond_variable_chemin).
function evaluateConditionClause(clause) {
const source = clause.source ?? clause.cond_source ?? 'objet';
const operator = clause.operator ?? clause.cond_operator;
const expected = clause.value ?? clause.cond_value;
if (source === 'variable') {
const varName = clause.variable ?? clause.cond_variable;
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
const varInfo = (gameData.variables || {})[varName];
const actual = resolveVariablePath(readVariableValue(varName), path);
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
return compareValues(actual, operator, expected, fieldType);
}
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
}
function evaluateConditionNode(node) {
if (node.cond_clauses) {
let clauses;
try { clauses = JSON.parse(node.cond_clauses); } catch (e) { clauses = null; }
if (Array.isArray(clauses) && clauses.length) {
const results = clauses.map(evaluateConditionClause);
return node.cond_combinator === 'ou' ? results.some(Boolean) : results.every(Boolean);
}
}
return evaluateConditionClause(node);
}
// Exécute le graphe de logique de l'écran courant à partir d'un nœud
// (typiquement un Déclencheur touché par le joueur). Un même point de
// sortie peut être relié à PLUSIEURS nœuds suivants (plusieurs actions
// pour un même déclencheur, ou plusieurs suites pour une même branche
// Vrai/Faux) — tous les fils sortants d'un port sont donc suivis, pas
// seulement le premier. Chaque nœud Action produit son effet puis
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode)
// quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un
// élément de jeu réutilisable, sur un de ses propres enfants. Sans ce
// paramètre, un tel déclencheur était bien retrouvé (findTriggerNode
// cherche dans tous les écrans) mais son graphe n'existait jamais dans
// l'écran réellement affiché : rien ne se déclenchait, silencieusement.
function runFlowFrom(nodeId, flowScreenId) {
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
if (!flow) return;
const nodesById = {};
flow.nodes.forEach(n => { nodesById[n.id] = n; });
function nextEdges(fromId, port) {
return flow.edges.filter(e => e.from_node_id === fromId && e.from_port === port);
}
function step(currentId) {
const node = nodesById[currentId];
if (!node) return;
if (node.node_type === 'trigger') {
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
} else if (node.node_type === 'condition') {
const port = evaluateConditionNode(node) ? 'vrai' : 'faux';
nextEdges(node.id, port).forEach(edge => step(edge.to_node_id));
} else if (node.node_type === 'action') {
runActionNode(node).then(() => {
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
});
}
}
step(nodeId);
}
// 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>