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
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>
261 lines
12 KiB
JavaScript
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?.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 = Number.parseFloat(parts[0]) || 9; const
|
|
h = Number.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);
|
|
});
|
|
}
|