Files
Forge-Engine/static/js/play/screens.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

179 lines
8.3 KiB
JavaScript

// ---------- Affichage des écrans, timeline d'animation ----------
// Extrait de templates/play.html (voir plan de modularisation — un
// fichier = une responsabilité, aussi pour le JS) : navigation entre
// écrans, proportion de la zone de jeu, lecture des clips d'animation
// posés dans l'éditeur ("Timeline d'animation").
// É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) {
// Déjà l'écran affiché : rien à refaire. Sans ce garde-fou, l'action
// "Ouvrir la ligne cliquée" (voir plus bas) — pensée pour révéler un
// détail SUR LE MÊME écran qu'un Répéteur, en la combinant avec une
// action "Modifier un élément → Visibilité" sur le même clic — rejouait
// à chaque clic sur une ligne les déclencheurs "À l'affichage de
// l'écran" et relançait la timeline d'animation depuis le début, ce qui
// donnait l'impression trompeuse d'un rechargement de page (éléments
// qui rejouent leur animation d'entrée, visibilité qu'un déclencheur
// "affichage" repasse à son état initial en écrasant l'action "Rendre
// visible" du même clic...). Aller vers un écran DIFFÉRENT continue de
// tout rejouer normalement, comme un vrai premier affichage.
if (id === window.currentScreenId) return;
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> de play.html : 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);
}
}
function screenIndexById(id) {
return screensData.findIndex(s => s.id === id);
}
// ---------- 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) {
// querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un
// écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres
// enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce
// modèle (rechargé en direct depuis le même écran-modèle, voir
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
if (!targets.length) return;
var duration = clip.duration || 1, delay = clip.delay || 0;
var iterationCount = clip.iteration_count || '1';
var infinite = iterationCount === 'infinite';
targets.forEach(function(target) {
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);
});
}
}
});
}
// Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE
// utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet
// écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser
// un autre modèle), avec une garde anti-boucle et un dédoublonnage par
// écran (un même modèle posé plusieurs fois ne doit pas répéter ses
// clips). Sans ça, une animation posée directement dans l'éditeur d'un
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
function collectAnimationClips(screenId) {
var visited = {};
var clips = [];
function visit(sid) {
if (visited[sid]) return;
visited[sid] = true;
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
if (!screenData) return;
screenData.elements.forEach(function(el) {
if (!el.element_type_id) return;
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
if (templateScreenId != null) visit(templateScreenId);
});
}
visit(screenId);
return clips;
}
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 = collectAnimationClips(screenId);
clips.forEach(function(clip) {
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
pendingAnimationTimers.push(t);
});
}