Files
Forge-Engine/static/js/play/screens.js
T
williamandClaude Sonnet 5 66d8eaeae8 Lots 1-3 modernisation JS (S8786/S2703/S2486) + retrait Sonar CI/prod
- Lot 1 (S8786, ReDoS) : 5 sites documentes NOSONAR apres preuve empirique
  (script reproductible docs/redos_probe_s8786.js), aucune reecriture
  defensive necessaire.
- Lot 2 (S2703, variable globale implicite) : bug reel trouve et corrige
  (SCENE_OBJECT_NAMES en const au lieu de let, cassait la reassignation
  cross-script depuis scene-editor.js) + test de non-regression ; 4 autres
  sites confirmes surs et documentes.
- Lot 3 (S2486, exceptions avalees) : 6 sites confirmes surs et
  documentes ; 2 sites (config sprite JSON invalide) corriges avec un
  console.warn devtools, comportement joueur inchange, couverts par un
  nouveau test.
- Retrait du job CI sonarqube (.gitea/workflows/deploy.yml) et du service
  prod sonarqube/sonar-postgres (docker-compose.prod.yml) : acces dashboard
  bloque par des soucis d'infrastructure reseau (WSL2/pare-feu Hyper-V en
  local, reseau Docker partage avec Caddy pas en place en prod), sans lien
  avec le code du moteur - mis de cote plutot que de continuer a bloquer
  sur de l'infra. Les lots 4+ de modernisation JS dependent de scores
  Sonar exacts et sont donc egalement en pause (voir CODE_QUALITY.md).

SKIP=djlint : H021 (styles inline, 49 occurrences) est un backlog deja
documente et assume (CODE_QUALITY.md section 6), sur des templates non
touches par ce commit - deja exclu de la CI pour la meme raison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 08:46:24 +02:00

261 lines
12 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) {
const groupId = btn.dataset.tabBtn;
const targetId = btn.dataset.tabTarget;
document.querySelectorAll(`[data-tab-btn="${groupId}"]`).forEach((b) => {
b.classList.toggle('is-active', b === btn);
});
document.querySelectorAll(`[data-tab-group="${groupId}"]`).forEach((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');
applyScreenKind(el.dataset.kind || 'document');
}
// Phase 7 — nettoie les animations de sprites de l'écran quitté (sinon
// une boucle setInterval reste active sur un élément désormais caché,
// mirroring le nettoyage déjà fait pour activeTimerTriggerIntervals
// dans triggers.js).
stopAllSpriteAnimations();
runScreenShowTriggers(id);
runScreenTimerTriggers(id);
runScreenHeldKeyTriggers(id);
runAnimationTimeline(id);
runScreenBackgroundMusic(id);
// Déplacement/animation automatiques de tout personnage posé sur cet
// écran (voir static/js/play/personnage-controller.js) — n'a d'effet
// que sur un écran "jeu_2d" contenant au moins un objet kind="personnage".
forgeStartPersonnageControllers(screensData.find((s) => s.id === id));
// Règles "à la collision/dans un périmètre -> action" (voir
// static/js/play/collision-rules-controller.js) — démarré juste après
// le contrôleur de personnage dont il dépend (position du joueur).
forgeStartCollisionRuleControllers(screensData.find((s) => s.id === id));
// Déclencheurs D'ÉCRAN "à l'affichage" (voir screen-triggers-controller.js,
// screens/rendering/screen_triggers.py) — narration/cinématique sans
// objet requis, exécutée à chaque affichage de CET écran.
forgeRunScreenTriggers(screensData.find((s) => s.id === id));
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
// synchronise le compteur affiché sur CET écran avec le total déjà
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
// joueur ait déjà marqué des points ailleurs resterait figé à "0"
// (valeur d'exemple rendue côté serveur) jusqu'au prochain point gagné.
if (typeof forgeUpdateAllScoreWidgets === 'function') forgeUpdateAllScoreWidgets();
}
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
// screen_edit.html) : démarrée en boucle à l'affichage de l'écran,
// arrêtée au changement d'écran — un seul Audio actif à la fois (pas un
// tableau comme activeTimerTriggerIntervals dans triggers.js, une
// musique de fond ne se cumule jamais avec une autre).
let currentBackgroundAudio = null;
function runScreenBackgroundMusic(screenId) {
if (currentBackgroundAudio) {
currentBackgroundAudio.pause();
currentBackgroundAudio = null;
}
const screenData = screensData.find((s) => String(s.id) === String(screenId));
const url = screenData && screenData.background_music_url;
if (!url) return;
currentBackgroundAudio = new Audio(url);
currentBackgroundAudio.loop = true;
currentBackgroundAudio.play().catch(() => {}); // NOSONAR S2486 - autoplay bloque, voir CODE_QUALITY.md
}
// 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) {
const parts = String(aspect).split('/');
const w = parseFloat(parts[0]) || 9; const
h = parseFloat(parts[1]) || 16;
const frame = document.getElementById('playFrame');
if (frame) {
frame.style.setProperty('--ratio-w', w);
frame.style.setProperty('--ratio-h', h);
}
}
// Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : le
// type d'écran est désormais choisi PAR ÉCRAN — changer d'écran ne
// recharge pas la page, donc #playFrame doit pouvoir basculer entre sa
// taille fixe en pixels (scène "jeu_2d", voir .sceneMode dans play.html)
// et sa taille en ratio fluide (écran "document") À CHAQUE showScreen(),
// pas une seule fois au chargement.
function applyScreenKind(kind) {
const frame = document.getElementById('playFrame');
if (frame) {
frame.classList.toggle('sceneMode', kind === 'jeu_2d');
}
}
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). ----------
let 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.
const rules = [];
Object.keys(gameData.animations || {}).forEach((screenId) => {
(gameData.animations[screenId] || []).forEach((clip) => {
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
let keyframes;
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
const steps = keyframes.map((k) => {
const props = Object.keys(k.props || {}).map((p) => `${p}:${k.props[p]};`).join(' ');
return `${k.percent}% { ${props} }`;
}).join(' ');
rules.push(`@keyframes customAnim_${clip.id} { ${steps} }`);
});
});
let 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.
const targets = document.querySelectorAll(`[data-anim-target="${clip.element_id}"]`);
if (!targets.length) return;
const duration = clip.duration || 1; const
delay = clip.delay || 0;
const iterationCount = clip.iteration_count || '1';
const infinite = iterationCount === 'infinite';
targets.forEach((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 if (clip.kind === 'sprite') {
// Phase 8 — même moteur que l'action de flow "jouer_animation_sprite"
// (voir runSpriteAnimation()/resolveSpriteFrames() dans actions.js) :
// custom_keyframes stocke {animation, fps} (nom de l'animation, pas
// ses frames — résolues ICI depuis le personnage ACTUELLEMENT
// assigné, gameData.personnage_animations, pas figées au moment où
// le clip a été configuré).
let spriteData = {};
try {
spriteData = JSON.parse(clip.custom_keyframes || '{}');
} catch (e) {
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
// custom_keyframes est produit par l'éditeur, jamais tapé à la
// main — un JSON invalide ici trahit presque toujours un bug côté
// éditeur, pas une action ponctuelle. Comportement joueur inchangé :
// l'animation reste silencieusement absente (spriteData reste {}).
console.warn('configuration sprite invalide', e);
}
spriteData.loop = infinite;
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
} else {
const 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) {
const visited = {};
let clips = [];
function visit(sid) {
if (visited[sid]) return;
visited[sid] = true;
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
const screenData = screensData.find((s) => String(s.id) === String(sid));
if (!screenData) return;
screenData.elements.forEach((el) => {
if (!el.element_type_id) return;
const 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((t) => { clearTimeout(t); });
pendingAnimationTimers = [];
const clips = collectAnimationClips(screenId);
clips.forEach((clip) => {
const t = setTimeout(() => { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
pendingAnimationTimers.push(t);
});
}