Self-hosting Font Awesome's font files (previous commit) didn't fix it either: every icon in the picker still failed identically. That rules out CDN/network blocking specifically and points to something in the browser blocking custom webfont loading altogether regardless of origin (common with strict anti-fingerprinting protection, e.g. Brave's font blocking or certain privacy extensions — @font-face loading is a well-known fingerprinting vector). Replaced the whole mechanism: each of the 258 curated icons is now a real downloaded SVG file (static/icons/*.svg, sourced from Font Awesome 5 Free's official SVG package) displayed via CSS mask-image (.icon-svg in style.css) instead of a font glyph. A masked SVG isn't a font resource at all, so it isn't subject to webfont-blocking — and it still colors via the existing "color" style property (background-color:currentColor) and sizes via font-size (1em), so no change to how the widget's other controls work. The "Icône" widget now stores just the icon's slug (e.g. "trophy") in a dedicated attribute instead of a full CSS class string, rendered by a new special_render (render_icone.py). Updated the add-gallery and the properties-panel picker modal to use the same mask technique for their previews, and element_add.py to validate the slug against the curated list. Removed the now-unused self-hosted Font Awesome CSS/webfont files and the <link> tags — no font dependency left for icons at all. Verified end to end (gallery renders with real SVG previews, simulated add creates a correctly-attributed element, the SVG file is actually served, the per-element picker reflects the current icon). Full suite green (89).
655 lines
32 KiB
HTML
655 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"><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>
|