Files
Forge-Engine/templates/play.html
T
williamandClaude Sonnet 5 b094342097 Ajoute "Ligne cliquée (Répéteur)" comme cible pour une Condition/action de la Logique de la scène
Problème remonté : un déclencheur "Au clic" posé sur un Répéteur exécute
le MÊME graphe pour n'importe quelle ligne cliquée - or une Condition
("Si is_opened est égal à Non") ou une action "Modifier une donnée" ne
pouvaient viser qu'une ligne FIXE, choisie à la création du nœud dans
l'éditeur. Impossible donc de dire "modifie le champ DE LA LIGNE QUE JE
VIENS DE CLIQUER", puisque cette ligne n'est justement jamais connue à
l'avance.

Nouvelle valeur sentinelle CLICKED_ROW_ID (-1, screens/flow/constants.py,
ne collisionne jamais avec un vrai id de ligne) proposée en tête de TOUTE
liste déroulante "Ligne concernée" (clause principale et clauses
supplémentaires d'un nœud Condition, cible d'une action "Modifier une
donnée") : "🖱️ Ligne cliquée (Répéteur)".

Résolution au moment de l'exécution, pas à la création du nœud :
- Condition (évaluée côté client) : readFieldValue() (play.html) résout
  -1 en window.lastClickedRowId, déjà capturé par bindClicks() au clic sur
  une ligne de Répéteur (déjà utilisé par "Ouvrir la ligne cliquée").
- Action "Modifier une donnée" (exécutée côté serveur) : le client envoie
  clicked_row_id dans le corps de la requête POST ; flow_node_run_data.py
  ne s'en sert que si le nœud vise justement CLICKED_ROW_ID, sinon la
  ligne fixe stockée sur le nœud reste utilisée normalement.

Ajoute tests/test_flow_clicked_row.py (ligne cliquée seule modifiée,
absence de clic = no-op plutôt que plantage, non-régression d'une cible
fixe, présence de l'option dans l'éditeur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:19:41 +02:00

680 lines
34 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="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<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">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.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) {
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) {
var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]');
if (!target) return;
var duration = clip.duration || 1, delay = clip.delay || 0;
var iterationCount = clip.iteration_count || '1';
var infinite = iterationCount === 'infinite';
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);
});
}
}
}
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 = (gameData.animations || {})[String(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.
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 found;
}
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.id);
});
}
});
}
// 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.id));
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id));
});
}
// 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;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (!wrapper || !el.rendered_html || el.rendered_html.indexOf(PLACEHOLDER_OPEN) === -1) return;
let html = el.rendered_html;
Object.keys(row).forEach(function(fieldName) {
if (fieldName === 'id') return;
const value = row[fieldName] == null ? '' : String(row[fieldName]);
const tmp = document.createElement('div');
tmp.textContent = value;
html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML);
});
wrapper.innerHTML = html;
});
}
// 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 + '"]');
if (!screenData || !screenDiv) return;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
if (!el.rendered_html) return;
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
// à une donnée) et "visibilityGated" (condition de visibilité —
// 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.
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return;
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (wrapper) wrapper.innerHTML = el.rendered_html;
});
applyOpenRowBindings();
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();
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;
}
}
// 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.
function evaluateConditionClause(clause) {
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, clause.operator ?? clause.cond_operator, clause.value ?? clause.cond_value, 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.
function runFlowFrom(nodeId) {
const flow = gameData.flows[String(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>