Files
williamandClaude Sonnet 5 ccf836f2c5
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier)
sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*,
.replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(),
getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y
(verifie site par site), extraction de template litteraux imbriques,
ternaires imbriquees, refactors de complexite cognitive (S3776) via
tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions
identiques (S4144). Deux exceptions S2486 documentees/corrigees
(filter-repeater-rows.js) et un cas S2703 de partage inter-scripts
complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js).
Details complets dans CODE_QUALITY.md section 5.

Restaure aussi le job CI "sonarqube" (non-bloquant) dans
.gitea/workflows/deploy.yml maintenant que l'instance prod est
operationnelle.

Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest).

SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021
(styles inline, 49 occurrences/6 templates) deja documente comme dette
assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec
ce commit (aucun template touche ici) - valide explicitement avec
l'utilisateur avant de contourner ce hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:38:29 +02:00

283 lines
15 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((n) => n.node_type === 'trigger' && n.trigger_event === 'affichage')
.forEach((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.
let activeTimerTriggerIntervals = [];
function runScreenTimerTriggers(screenId) {
activeTimerTriggerIntervals.forEach((id) => { clearInterval(id); });
activeTimerTriggerIntervals = [];
const flow = gameData.flows[String(screenId)];
if (!flow) return;
flow.nodes
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0)
.forEach((n) => {
const intervalId = setInterval(() => { 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((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((el) => {
if (el.dataset.hoverBound) return;
if (el.children.length > 0) return;
el.dataset.hoverBound = '1';
const original = el.textContent;
el.addEventListener('mouseenter', () => { el.textContent = el.dataset.hoverText; });
el.addEventListener('mouseleave', () => { el.textContent = original; });
});
}
// RGAA/WCAG 2.1.1 (clavier) : un objet "Au clic" n'était auparavant
// actionnable qu'à la souris (simple <div>/<img>, jamais un <button>/<a>
// natif — voir screens/scenes/render_scene_object.py) — inatteignable et
// inactivable au clavier. tabindex="0" + role="button" le rendent
// focusable (sauf s'il l'est déjà — un <button>/<a> auteur, ex. widget
// "bouton"/"lien", ne doit pas se voir écraser son rôle natif) ; Entrée/
// Espace déclenchent le même comportement que le clic.
function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
const elementId = Number.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';
if (!elDiv.hasAttribute('tabindex')) elDiv.setAttribute('tabindex', '0');
const tag = elDiv.tagName;
if (!elDiv.hasAttribute('role') && tag !== 'BUTTON' && tag !== 'A' && tag !== 'INPUT') {
elDiv.setAttribute('role', 'button');
}
const activate = (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 ? Number.parseInt(rowEl.dataset.rowId, 10) : null;
window.lastClickedDefinitionId = elDiv.dataset.definitionId
? Number.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));
};
elDiv.addEventListener('click', activate);
elDiv.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
activate(ev);
});
}
});
}
// 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).
// RGAA/WCAG 2.1.1 (clavier) : "Au survol"/"Fin du survol" ne réagissaient
// qu'à la souris (mouseenter/mouseleave) — focus/blur est l'équivalent
// clavier standard (recevoir le focus = "survoler", le perdre = "fin du
// survol"), donc un élément SEULEMENT concerné par le survol (jamais par
// bindClicks() ci-dessus) doit aussi devenir focusable pour rester
// atteignable au clavier.
function bindHoverTriggers() {
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
if (elDiv.dataset.hoverTriggerBound) return;
const elementId = Number.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';
if (!elDiv.hasAttribute('tabindex')) elDiv.setAttribute('tabindex', '0');
// 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) {
const onEnter = () => enterTriggers.forEach((t) => runFlowFrom(t.node.id, t.screenId));
elDiv.addEventListener('mouseenter', onEnter);
elDiv.addEventListener('focus', onEnter);
}
if (leaveTriggers.length) {
const onLeave = () => leaveTriggers.forEach((t) => runFlowFrom(t.node.id, t.screenId));
elDiv.addEventListener('mouseleave', onLeave);
elDiv.addEventListener('blur', onLeave);
}
});
}
// 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.
// Empêche le comportement par défaut du navigateur pour une touche que LE
// JEU écoute (ex. Espace/Flèches font défiler la page, et Espace "clique"
// en plus le bouton actuellement focus — souvent le bouton "Jouer" qui a
// gardé le focus après avoir ouvert l'aperçu) — sans ça, sauter et se
// déplacer en même temps pouvait sembler ne pas marcher : la touche
// réactivait/scrollait la page au lieu de se contenter de jouer le jeu.
// Jamais pour une touche qui n'est PAS écoutée par le jeu (laisse tel
// quel un champ de saisie ailleurs sur la page, par ex.).
function isGameKey(key) {
for (const screenIdKey in gameData.flows) {
if (gameData.flows[screenIdKey].nodes.some((n) => n.node_type === 'trigger'
&& (n.trigger_event === 'clavier' || n.trigger_event === 'touche_maintenue' || n.trigger_event === 'touche_relachee')
&& n.trigger_key === key)) return true;
}
return false;
}
function bindKeyboardTriggers() {
window.addEventListener('keydown', (ev) => {
if (isGameKey(ev.key)) ev.preventDefault();
for (const screenIdKey in gameData.flows) {
gameData.flows[screenIdKey].nodes
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key)
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
}
});
}
// Mouvement continu (jeu 2D) — répond au manque signalé par l'utilisateur :
// "clavier" ci-dessus ne se déclenche qu'UNE FOIS par appui (keydown),
// insuffisant pour "maintenir une touche fait avancer le personnage".
// heldKeys suit les touches actuellement enfoncées (Set, indépendant de la
// répétition OS du keydown — un keydown répété pour la même touche ne fait
// que la re-ajouter, sans effet). Un SEUL écouteur global (comme
// bindKeyboardTriggers ci-dessus), lu à intervalle régulier PAR ÉCRAN (voir
// runScreenHeldKeyTriggers, appelée depuis showScreen() comme
// runScreenTimerTriggers). "touche_relachee" (keyup), lui, n'a pas besoin
// d'être scopé par écran — un seul déclenchement ponctuel, même patron de
// scan global que "clavier".
const heldKeys = new Set();
function bindHeldKeyTriggers() {
window.addEventListener('keydown', (ev) => {
if (isGameKey(ev.key)) ev.preventDefault();
heldKeys.add(ev.key);
});
window.addEventListener('keyup', (ev) => {
heldKeys.delete(ev.key);
for (const screenIdKey in gameData.flows) {
gameData.flows[screenIdKey].nodes
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'touche_relachee' && n.trigger_key === ev.key)
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
}
});
// La fenêtre peut perdre le focus touche encore enfoncée (alt-tab...) —
// sans ça, heldKeys garderait une touche "fantôme" indéfiniment maintenue.
window.addEventListener('blur', () => { heldKeys.clear(); });
}
// Même patron PAR ÉCRAN que runScreenTimerTriggers() ci-dessus : n'existe
// que tant que l'écran qui porte le déclencheur est affiché, réinitialisé
// à chaque changement d'écran (voir showScreen(), static/js/play/screens.js).
// 50ms (20 fois/seconde) : assez fluide pour un déplacement perçu comme
// continu, sans re-exécuter le graphe à une fréquence excessive.
let activeHeldKeyTriggerInterval = null;
function runScreenHeldKeyTriggers(screenId) {
if (activeHeldKeyTriggerInterval) {
clearInterval(activeHeldKeyTriggerInterval);
activeHeldKeyTriggerInterval = null;
}
const flow = gameData.flows[String(screenId)];
if (!flow) return;
const nodes = flow.nodes.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'touche_maintenue');
if (!nodes.length) return;
activeHeldKeyTriggerInterval = setInterval(() => {
nodes.filter((n) => heldKeys.has(n.trigger_key))
.forEach((n) => { runFlowFrom(n.id, screenId); });
}, 50);
}
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
if (typeof module !== 'undefined' && module.exports) {
module.exports = { isGameKey, heldKeys };
}