Files
Forge-Engine/static/js/play/actions.js
T
williamandClaude Sonnet 5 dbada333d5 Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS
templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:59:11 +02:00

194 lines
9.4 KiB
JavaScript

// ---------- Exécution des actions ----------
// Extrait de templates/play.html (voir plan de modularisation) :
// application d'une propriété d'élément ("Modifier un élément") et
// exécution de chaque type de nœud Action. Les URLs de routes serveur
// (flow_node_run_data/run_variable) sont injectées par play.html dans
// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici).
// 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> de play.html), 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 dans triggers.js) puis affiche l'écran de
// détail avec ses placeholders "champ" résolus contre cette ligne
// précise.
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
}
showScreen(node.target_screen_id);
applyOpenRowBindings();
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
// le même conteneur — applyOpenRowBindings() vient de régénérer son
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
// lignes muettes dès le clic suivant, sans la moindre erreur.
bindClicks();
bindHoverTexts();
bindHoverTriggers();
return Promise.resolve();
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
// Notification pure (voir screens/custom_events/) : ne fait que
// signaler l'événement, jamais choisir un élément — c'est à
// l'écouteur (déclencheur → condition → action) de décider quoi
// faire, avec ses propres réglages habituels.
window.dispatchGameEvent(node.target_custom_event_id);
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 = FORGE_PLAY_URLS.flowNodeRunData.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 = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
return fetch(runVarUrl, {method: 'POST'})
.then(r => r.json())
.then(() => refreshRuntimeData());
}
// 'rien' : aucune action volontairement.
return Promise.resolve();
}