Icons weren't rendering (glyph placeholder text like "F085" showing instead of the actual icon — the browser's font-fallback behavior when no font can render that codepoint, meaning the FA webfont never loaded). Both CDN URLs resolve fine from here, but Bulma (already jsdelivr) renders correctly for the user while cdnjs-hosted Font Awesome didn't — switching to the same jsdelivr provider removes that difference as a variable.
657 lines
32 KiB
HTML
657 lines
32 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">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.4/css/all.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) et "jaugeBar" (2.1 — jauge liée
|
|
// à une donnée) sont les deux seuls types d'éléments dont le
|
|
// contenu affiché 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.
|
|
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -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);
|
|
return fetch(runDataUrl, {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) {
|
|
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;
|
|
const e = (expected === '1' || expected === 'true' || expected === true) ? 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>
|