Répond au manque signalé par l'utilisateur : le déclencheur "clavier"
existant (keydown) ne se déclenche qu'UNE FOIS par appui, insuffisant
pour "maintenir une touche fait avancer/sauter le personnage en continu".
- Deux nouveaux déclencheurs (screens/flow/constants.py,
screens/scenes/flow_palette.py) : "Tant qu'une touche est maintenue"
(se répète ~20 fois/seconde tant que la touche reste enfoncée,
runScreenHeldKeyTriggers() dans triggers.js — même patron PAR ÉCRAN que
"minuteur", arrêté au changement d'écran) et "Au relâchement d'une
touche" (un seul déclenchement, scan global comme "clavier"). Combinés
à l'action existante "Modifier un objet de scène → Déplacer de... px
(relatif)", ça permet un vrai déplacement continu.
- preventDefault() sur toute touche que le jeu écoute réellement
(isGameKey(), triggers.js) : Espace/Flèches font défiler la page par
défaut, et Espace réactive en plus le bouton actuellement focus (souvent
le bouton "Jouer" qui garde le focus après l'ouverture de l'aperçu) —
ça pouvait donner l'impression qu'une touche du jeu ne faisait rien.
- Le personnage pouvait sortir du cadre de la scène en se déplaçant :
applyObjectProperty()/clampSceneObjectPosition() (static/js/play/actions.js)
bornent maintenant toute position (absolue ou relative) à
[0, scene_width/height − la taille de l'objet].
- Vitesse d'animation par défaut adaptée au nombre d'images : la valeur
fixe (8 i/s) venait d'un formulaire pensé pour les cycles Kenney (8
images) — un cycle CraftPix (walk=30 images) au même 8 i/s prenait
~4 secondes, "très lent". Le choix d'une animation dans la galerie
calcule maintenant une vitesse par défaut proportionnelle à son nombre
d'images (flow-editor.js, animation-timeline.js).
- Priorité d'animation (bug : "je ne peux pas me déplacer et sauter") :
un déclencheur de déplacement (touche maintenue) redemande "marche" à
chaque tick, écrasant aussitôt une animation ponctuelle ("sauter")
démarrée entre-temps avant qu'elle ait pu s'afficher. runSpriteAnimation()
(actions.js) laisse maintenant une animation NON BOUCLÉE en cours
(même à une seule frame, ex. une pose Kenney figée) aller jusqu'au bout
avant qu'une autre demande puisse l'interrompre.
Nouveaux tests : static/js/play/__tests__/{actions,triggers}.test.js
(idempotence + priorité d'animation, bornage aux limites de la scène,
isGameKey) ; tests/test_scene_edit_view.py (persistance d'un nœud
"touche_maintenue").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
10 KiB
JavaScript
218 lines
10 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');
|
|
}
|
|
// 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);
|
|
}
|
|
|
|
// 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).
|
|
var currentBackgroundAudio = null;
|
|
|
|
function runScreenBackgroundMusic(screenId) {
|
|
if (currentBackgroundAudio) {
|
|
currentBackgroundAudio.pause();
|
|
currentBackgroundAudio = null;
|
|
}
|
|
var screenData = screensData.find(function(s) { return String(s.id) === String(screenId); });
|
|
var url = screenData && screenData.background_music_url;
|
|
if (!url) return;
|
|
currentBackgroundAudio = new Audio(url);
|
|
currentBackgroundAudio.loop = true;
|
|
currentBackgroundAudio.play().catch(function() {});
|
|
}
|
|
|
|
// 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 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é).
|
|
var spriteData = {};
|
|
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ }
|
|
spriteData.loop = infinite;
|
|
setTimeout(function() { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
|
} 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);
|
|
});
|
|
}
|