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>
234 lines
12 KiB
JavaScript
234 lines
12 KiB
JavaScript
// ---------- Exécution des actions ----------
|
||
// Extrait de templates/play.html (voir plan de modularisation) :
|
||
// application d'une propriété d'élément ("Modifier un élément") et
|
||
// exécution de chaque type de nœud Action. Les URLs de routes serveur
|
||
// (flow_node_run_data/run_variable) sont injectées par play.html dans
|
||
// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici).
|
||
|
||
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
|
||
// pilotable par une action "Modifier un élément".
|
||
var ELEMENT_PROPERTY_CSS = {
|
||
bg_color: { css: 'backgroundColor', suffix: '' },
|
||
border_color: { css: 'borderColor', suffix: '' },
|
||
width: { css: 'width', suffix: '%' },
|
||
height: { css: 'height', suffix: '%' },
|
||
// Phase 5 (moteur) — les éléments sont DÉJÀ positionnés en absolu avec
|
||
// left/top en % (voir filters/element_style_filter.py) : piloter ces
|
||
// deux propriétés est donc cohérent avec l'existant, pas une nouvelle
|
||
// mécanique de positionnement.
|
||
pos_x: { css: 'left', suffix: '%' },
|
||
pos_y: { css: 'top', suffix: '%' },
|
||
};
|
||
|
||
function applyElementProperty(targetEl, property, value) {
|
||
if (property === 'contenu') {
|
||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||
// binding vers un champ d'objet ici (contrairement à
|
||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||
// imbriqué, à éviter.
|
||
targetEl.textContent = value == null ? '' : value;
|
||
return;
|
||
}
|
||
if (property === 'surbrillance') {
|
||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
||
if (value === 'toggle') {
|
||
targetEl.classList.toggle('forgeHighlight');
|
||
} else if (value === 'off') {
|
||
targetEl.classList.remove('forgeHighlight');
|
||
} else {
|
||
targetEl.classList.add('forgeHighlight');
|
||
}
|
||
return;
|
||
}
|
||
if (property === 'desactive') {
|
||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
||
if (value === 'toggle') {
|
||
targetEl.classList.toggle('forgeDisabled');
|
||
} else if (value === 'off') {
|
||
targetEl.classList.remove('forgeDisabled');
|
||
} else {
|
||
targetEl.classList.add('forgeDisabled');
|
||
}
|
||
return;
|
||
}
|
||
if (property === 'visibilite') {
|
||
if (value === 'toggle') {
|
||
const hidden = targetEl.style.display === 'none';
|
||
targetEl.style.display = hidden ? '' : 'none';
|
||
targetEl.style.visibility = '';
|
||
} else if (value === 'masque') {
|
||
targetEl.style.display = 'none';
|
||
} else if (value === 'invisible') {
|
||
targetEl.style.display = '';
|
||
targetEl.style.visibility = 'hidden';
|
||
} else {
|
||
targetEl.style.display = '';
|
||
targetEl.style.visibility = '';
|
||
}
|
||
return;
|
||
}
|
||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||
// toujours au même endroit.
|
||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||
const delta = parseFloat(value);
|
||
if (isNaN(delta)) return;
|
||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||
return;
|
||
}
|
||
|
||
const spec = ELEMENT_PROPERTY_CSS[property];
|
||
if (!spec || value === undefined || value === null) return;
|
||
|
||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||
// 1re valeur si l'écran est rechargé.
|
||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||
const parts = value.split('||');
|
||
const key = 'toggle' + property;
|
||
const atSecond = targetEl.dataset[key] === '1';
|
||
const next = atSecond ? parts[0] : parts[1];
|
||
targetEl.style[spec.css] = next + spec.suffix;
|
||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||
return;
|
||
}
|
||
|
||
targetEl.style[spec.css] = value + spec.suffix;
|
||
}
|
||
|
||
function runActionNode(node) {
|
||
if (node.action_type === 'ecran_suivant') {
|
||
const idx = screenIndexById(window.currentScreenId);
|
||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'ecran_precedent') {
|
||
const idx = screenIndexById(window.currentScreenId);
|
||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||
showScreen(node.target_screen_id);
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||
// précise.
|
||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||
}
|
||
showScreen(node.target_screen_id);
|
||
applyOpenRowBindings();
|
||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||
bindClicks();
|
||
bindHoverTexts();
|
||
bindHoverTriggers();
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||
// signaler l'événement, jamais choisir un élément — c'est à
|
||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||
// faire, avec ses propres réglages habituels.
|
||
window.dispatchGameEvent(node.target_custom_event_id);
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||
const allElements = screenData ? screenData.elements : [];
|
||
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
|
||
if (target) {
|
||
const parentKey = target.parent_id || null;
|
||
allElements
|
||
.filter(function(e){ return (e.parent_id || null) === parentKey; })
|
||
.forEach(function(e) {
|
||
const el = document.querySelector('[data-element-id="' + e.id + '"]');
|
||
if (!el) return;
|
||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
|
||
else { el.style.display = 'none'; }
|
||
});
|
||
}
|
||
return Promise.resolve();
|
||
} else if (node.action_type === 'attendre') {
|
||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||
// quelques secondes après l'affichage de l'écran, sans action du
|
||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||
const seconds = parseFloat(node.data_value);
|
||
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||
} else if (node.action_type === 'modifier_donnee') {
|
||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||
// clicked_row_id/last_inserted_row_id : utilisés côté serveur
|
||
// SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou
|
||
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
|
||
// sinon, donc toujours transmis sans condition ici.
|
||
return fetch(runDataUrl, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId })
|
||
})
|
||
.then(r => r.json())
|
||
.then(() => refreshRuntimeData());
|
||
} else if (node.action_type === 'modifier_variable') {
|
||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||
return fetch(runVarUrl, {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(() => refreshRuntimeData());
|
||
} else if (node.action_type === 'ajouter_ligne') {
|
||
// Phase 4 — crée une ligne VIDE côté serveur ; son id devient
|
||
// window.lastInsertedRowId pour que les nœuds "Modifier une donnée"
|
||
// enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕ Dernière ligne
|
||
// ajoutée") sachent quelle ligne renseigner. refreshRuntimeData()
|
||
// affiche la nouvelle ligne dans tout Répéteur lié dès ce round-trip.
|
||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||
return fetch(runAddRowUrl, { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(function(json) {
|
||
window.lastInsertedRowId = json.row_id;
|
||
return refreshRuntimeData();
|
||
});
|
||
} else if (node.action_type === 'jouer_son') {
|
||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
|
||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
||
return Promise.resolve();
|
||
}
|
||
// 'rien' : aucune action volontairement.
|
||
return Promise.resolve();
|
||
}
|