Ajoute deux mécanismes distincts, à la demande de l'utilisateur qui a précisé qu'un son doit pouvoir être attaché à une SCÈNE (pas seulement joué ponctuellement par une action de flow, comme prévu initialement) : - Musique de fond par écran (nouveau champ background_music_url sur _screens, ALTER TABLE nullable) : réglée dans le panneau gauche de l'éditeur (URL ou envoi de fichier, réutilise la route d'upload générique existante), enregistrée en AJAX au même patron que le format d'aperçu (screen_set_aspect.py). Démarrée en boucle à l'affichage de l'écran et arrêtée au changement d'écran (runScreenBackgroundMusic(), appelée depuis showScreen() dans static/js/play/screens.js) — un seul Audio actif à la fois, jamais cumulé avec une musique restée d'un écran précédent. - Action de flow "jouer_son" (aux côtés des actions existantes) : effet sonore ponctuel, non bouclé, déclenchable sur n'importe quel nœud Déclencheur. Réutilise data_value (déjà un champ texte générique sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle colonne dédiée — fire-and-forget côté client (runActionNode), ne bloque jamais la suite du graphe. Les deux réutilisent le même mécanisme d'upload de fichier déjà en place ailleurs dans l'éditeur (ex. source d'une vidéo), sans nouvelle route. Ceci complète les 6 phases du plan d'extension du moteur (état par joueur, hasard/maths, clavier/minuteur, ajout de ligne, position/ collision, son) : Forge Engine peut désormais couvrir des jeux bien au-delà du narratif/puzzle/quiz (action, arcade, jeux à contrainte de temps). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
201 lines
9.2 KiB
JavaScript
201 lines
9.2 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);
|
|
runScreenTimerTriggers(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 {
|
|
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);
|
|
});
|
|
}
|