screens/flow/constants.py : TRIGGER_EVENTS += "clavier" (À l'appui sur une touche) et "minuteur" (Toutes les X millisecondes) — ni élément ni écran précis pour les deux, comme "evenement" déjà en place. FLOW_NODE_FIELDS += trigger_key/trigger_interval_ms. ensure_flow_schema.py : ALTER TABLE pour les 2 colonnes (patron trigger_custom_event_id). templates/screen_edit.html + static/js/screen_edit/flow-editor.js : - "clavier" : un champ "Touche à surveiller" qui capture lui-même la touche pressée (onkeydown sur l'input, captureFlowTriggerKey()) plutôt que de faire deviner la syntaxe attendue (ev.key du navigateur, ex. "ArrowUp", "a", " " pour Espace). - "minuteur" : un simple champ numérique (millisecondes). - nodeLabel() affiche "⌨️ Touche « X »"/"⏱️ Toutes les N ms" sur le nœud. static/js/play/triggers.js (moteur de jeu) : - bindKeyboardTriggers() : UN SEUL window.addEventListener('keydown', ...) posé une fois pour tout le jeu (voir l'amorçage en fin de templates/play.html) — même patron de scan global que dispatchGameEvent() pour "Sur un événement personnalisé". - runScreenTimerTriggers(screenId) : géré PAR ÉCRAN (appelé depuis showScreen(), static/js/play/screens.js) — démarre les setInterval des nœuds "minuteur" de l'écran affiché, arrête d'abord tous ceux de l'affichage précédent (même principe que runAnimationTimeline) pour ne jamais accumuler des minuteurs sur des écrans quittés. Vérifié : 255 tests passent (5 nouveaux, dont un qui verrouille que la touche Espace — très probablement utilisée en jeu — n'est pas filtrée comme une valeur "vide" par add_flow_node.py), 13 tests node:test toujours au vert, syntaxe JS validée sur tous les fichiers de static/js/play/ et static/js/screen_edit/. Comme le reste du graphe de logique côté client, le comportement RÉEL d'un keydown/setInterval n'est pas testable sans navigateur — test manuel recommandé (touche assignée à un saut, minuteur faisant avancer un compteur). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
173 lines
9.6 KiB
JavaScript
173 lines
9.6 KiB
JavaScript
// ---------- Déclencheurs : recherche des nœuds + attache des écouteurs ----------
|
|
// Extrait de templates/play.html (voir plan de modularisation).
|
|
|
|
// 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); });
|
|
}
|
|
|
|
// Minuteur récurrent (Phase 3) : contrairement aux autres déclencheurs
|
|
// (réagissent à quelque chose), celui-ci fait avancer le jeu tout seul, à
|
|
// intervalle régulier — géré PAR ÉCRAN (appelé depuis showScreen(),
|
|
// static/js/play/screens.js) : n'existe QUE tant que l'écran qui le
|
|
// porte est affiché. On arrête d'abord tout minuteur de l'affichage
|
|
// précédent (même principe que runAnimationTimeline) — sans ça, revenir
|
|
// plusieurs fois sur le même écran accumulerait des setInterval en
|
|
// double, chacun exécutant le graphe en plus des autres.
|
|
var activeTimerTriggerIntervals = [];
|
|
|
|
function runScreenTimerTriggers(screenId) {
|
|
activeTimerTriggerIntervals.forEach(function(id) { clearInterval(id); });
|
|
activeTimerTriggerIntervals = [];
|
|
const flow = gameData.flows[String(screenId)];
|
|
if (!flow) return;
|
|
flow.nodes
|
|
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0; })
|
|
.forEach(function(n){
|
|
var intervalId = setInterval(function(){ runFlowFrom(n.id, screenId); }, n.trigger_interval_ms);
|
|
activeTimerTriggerIntervals.push(intervalId);
|
|
});
|
|
}
|
|
|
|
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
|
|
// TOUS les nœuds Déclencheur qui lui sont rattachés — ce sont les points
|
|
// d'entrée des graphes à exécuter. Renvoie un TABLEAU (jamais un seul
|
|
// nœud) : rien n'empêche de poser plusieurs déclencheurs indépendants
|
|
// "Au clic" sur le MÊME élément (ex. l'un ouvre une ligne de Répéteur,
|
|
// l'autre éteint une surbrillance) — un seul clic doit alors exécuter
|
|
// CHACUN de ces graphes, pas seulement le premier trouvé (régression
|
|
// vécue : un second déclencheur posé sur un élément qui en avait déjà un
|
|
// restait silencieusement inerte, aucune erreur, juste jamais exécuté).
|
|
//
|
|
// On cherche dans TOUS les écrans (pas seulement l'écran courant) :
|
|
// bindClicks() attache les gestionnaires une seule fois, sur tous les
|
|
// éléments de tous les écrans (déjà tous présents dans le DOM, seuls les
|
|
// écrans non actifs sont masqués) — chaque identifiant d'élément est
|
|
// unique dans tout le jeu, donc pas d'ambiguïté possible. Cette recherche
|
|
// globale couvre aussi les écrans-MODÈLES (un élément de jeu réutilisable,
|
|
// ex. "mail content", posé sur une scène) : un déclencheur "Au clic"/"Au
|
|
// survol" posé dans l'éditeur du modèle, sur un de SES PROPRES enfants,
|
|
// est donc retrouvé même quand ce modèle est utilisé ailleurs — d'où le
|
|
// besoin de renvoyer aussi l'écran d'origine de CHAQUE nœud (screenIdKey),
|
|
// pas seulement le nœud : runFlowFrom() doit exécuter le graphe dans CET
|
|
// écran-là (celui du modèle), jamais dans celui réellement affiché, sans
|
|
// quoi le nœud trouvé n'existerait pas dans le graphe utilisé et l'action
|
|
// ne se déclencherait jamais.
|
|
function findTriggerNodess(elementId, event) {
|
|
const found = [];
|
|
for (const screenIdKey in gameData.flows) {
|
|
gameData.flows[screenIdKey].nodes.forEach(function(n) {
|
|
if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
|
|
found.push({ node: n, screenId: screenIdKey });
|
|
}
|
|
});
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// 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 triggers = findTriggerNodess(elementId, 'clic');
|
|
if (triggers.length) {
|
|
// 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;
|
|
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
|
// seulement le premier (voir le commentaire de findTriggerNodess).
|
|
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Déclencheurs "Au survol" / "Fin du survol" — même principe que
|
|
// bindClicks() (garde-fou anti-doublon compris), mais sur mouseenter/
|
|
// mouseleave plutôt que click, et les deux événements cherchés
|
|
// séparément puisque ce sont deux déclencheurs distincts et indépendants
|
|
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
|
|
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
|
|
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
|
|
function bindHoverTriggers() {
|
|
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
|
if (elDiv.dataset.hoverTriggerBound) return;
|
|
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
|
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
|
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
|
if (!enterTriggers.length && !leaveTriggers.length) return;
|
|
elDiv.dataset.hoverTriggerBound = '1';
|
|
// Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
|
|
// le commentaire de findTriggerNodess (plusieurs déclencheurs
|
|
// indépendants sur le même élément/événement doivent tous s'exécuter).
|
|
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
|
|
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
|
|
});
|
|
}
|
|
|
|
// Déclencheur clavier (Phase 3) : ni élément ni écran précis — un SEUL
|
|
// écouteur posé une fois pour tout le jeu (voir l'amorçage en fin de
|
|
// play.html), même principe de scan global que dispatchGameEvent()
|
|
// (flow-engine.js) pour "Sur un événement personnalisé" : gameData.flows
|
|
// est parcouru à CHAQUE appui, pas une seule fois au chargement, pour
|
|
// que les nœuds ajoutés/déplacés après un rafraîchissement des données
|
|
// restent pris en compte sans devoir ré-attacher quoi que ce soit.
|
|
function bindKeyboardTriggers() {
|
|
window.addEventListener('keydown', function (ev) {
|
|
for (const screenIdKey in gameData.flows) {
|
|
gameData.flows[screenIdKey].nodes
|
|
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key; })
|
|
.forEach(function(n){ runFlowFrom(n.id, screenIdKey); });
|
|
}
|
|
});
|
|
}
|