Deuxième et dernier commit de la Phase A du plan "Fondations d'une
plateforme multi-éditeurs" (le premier, efa7d1d, posait le schéma et le
CRUD des objets de scène). Ce commit branche l'éditeur et le mode
jouable sur ces fondations, en réutilisant TEL QUEL tout ce qui est déjà
générique côté moteur de logique.
Éditeur (routes/scenes/, templates/scene_edit.html,
static/js/scenes/scene-editor.js) :
- routes/screens/screen_edit.py délègue à render_scene_edit() dès que
game_type == "jeu_2d" — même endpoint Flask "screen_edit" pour les
deux éditeurs, aucune route dupliquée.
- Nouveau template scene_edit.html : canevas à taille FIXE en pixels
(scene_width/scene_height) avec glisser-déposer/redimensionnement en
pixels (scene-editor.js, mirror pixel de tree-panels.js), galerie
personnages/décors. Les onglets "Blocs de logique"/"Timeline
d'animation"/"Événements" réutilisent tels quels flow-editor.js,
tabs-and-blocks.js et animation-timeline.js.
- flow-editor.js : nouveau flag FLOW_TARGETS_OBJECTS (false par défaut,
true dans scene_edit.html) qui fait écrire submitNodeForm() vers
trigger_object_id/target_object_id au lieu de trigger_element_id/
target_element_id, plus une branche "Modifier un objet de scène"
(position px, visibilité, orientation) et des gardes null partout
(le DOM de scene_edit.html n'a pas tous les champs de l'éditeur
document).
- blocks_view.py étend l'ensemble d'ids d'éléments concernés par un
bloc pour inclure aussi trigger_object_id/target_object_id.
Runtime jouable (templates/play.html, static/js/play/actions.js,
screens/payload/full_game_payload.py) :
- full_game_payload() construit les écrans d'un jeu jeu_2d à partir de
_scene_objects (render_scene_object) au lieu de _screen_elements, et
récupère les animations de personnage sur les objets de scène.
- play.html : #playFrame occupe tout le viewport pour une scène jeu_2d
(pas de ratio fluide) ; la scène se centre via .playScreen.playScene,
qui réutilise tel quel showScreen() (déjà indexé par id d'écran, pas
par forme DOM) — aucun fichier "scenes.js" séparé n'a été nécessaire.
- actions.js : jouer_animation_sprite retombe sur target_object_id si
target_element_id est absent ; nouvelle action modifier_objet_scene
avec applyObjectProperty() (positions en px, contrairement aux % de
applyElementProperty()).
Un bug de gabarit a été découvert et corrigé pendant la vérification :
le commentaire CSS de play.html contenait littéralement "{% for %}"
comme texte français, que Jinja interprétait comme une vraie balise et
faisait planter le rendu — reformulé. render_scene_object() n'était en
outre jamais appelé par la vue de l'éditeur (les objets de scène
s'affichaient sans image) — scene_edit_view.py attache maintenant
rendered_html à chaque objet avant de les passer au template.
Nouveaux tests (tests/test_scene_edit_view.py, 10 cas) : dispatch
document vs jeu_2d, rendu d'un personnage sélectionné, CRUD géométrie/
suppression d'objet, nœuds de flow ciblant un objet de scène (action et
condition de collision), payload et page /play pour un jeu jeu_2d,
présence des nouveaux helpers JS. Suite complète : 309 tests passent
(299 existants + 10 nouveaux, aucune régression). node --check et
node --test (13 tests JS) passent également.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
354 lines
18 KiB
JavaScript
354 lines
18 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 === 'orientation') {
|
||
// Phase 7 — retourne le personnage en miroir (voir .forgeFlipped dans
|
||
// play.html) plutôt que de nécessiter une 2e feuille de sprites "vue
|
||
// de dos" pour chaque sens de déplacement.
|
||
if (value === 'toggle') {
|
||
targetEl.classList.toggle('forgeFlipped');
|
||
} else if (value === 'gauche') {
|
||
targetEl.classList.add('forgeFlipped');
|
||
} else {
|
||
targetEl.classList.remove('forgeFlipped');
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent
|
||
// d'applyElementProperty() ci-dessus pour un objet de scène (action
|
||
// "Modifier un objet de scène"), mais en PIXELS (pas de %, voir
|
||
// scene_width/scene_height) — visibilite/orientation restent identiques
|
||
// à l'éditeur document (déléguées à applyElementProperty), seule la
|
||
// position change de suffixe.
|
||
function applyObjectProperty(targetEl, property, value) {
|
||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||
const delta = parseFloat(value);
|
||
if (isNaN(delta)) return;
|
||
const current = parseFloat(targetEl.style[css]) || 0;
|
||
targetEl.style[css] = (current + delta) + 'px';
|
||
return;
|
||
}
|
||
if (property === 'pos_x' || property === 'pos_y') {
|
||
if (value === undefined || value === null) return;
|
||
targetEl.style[property === 'pos_x' ? 'left' : 'top'] = value + 'px';
|
||
return;
|
||
}
|
||
applyElementProperty(targetEl, property, value);
|
||
}
|
||
|
||
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 === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||
// "modifier_element" ci-dessus, mais en pixels (voir
|
||
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
||
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
||
// render_scene_object.py), donc le même sélecteur suffit.
|
||
const targetEl = document.querySelector('[data-element-id="' + (node.target_element_id || node.target_object_id) + '"]');
|
||
if (targetEl) applyObjectProperty(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();
|
||
} else if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||
const targetEl = document.querySelector('[data-element-id="' + spriteTargetId + '"]');
|
||
if (targetEl) {
|
||
let spriteData = {};
|
||
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
||
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
|
||
}
|
||
return Promise.resolve();
|
||
}
|
||
// 'rien' : aucune action volontairement.
|
||
return Promise.resolve();
|
||
}
|
||
|
||
// ---------- Phase 7 — animation de sprites (poses/images successives) ----------
|
||
// Premier état actif PAR ÉLÉMENT du moteur (contrairement à
|
||
// activeTimerTriggerIntervals dans triggers.js et currentBackgroundAudio
|
||
// dans screens.js, qui sont par-écran/singleton) : plusieurs personnages
|
||
// peuvent animer indépendamment en même temps. Clé = le NŒUD DOM lui-même
|
||
// (pas element_id) : un clip posé sur un écran-modèle peut viser plusieurs
|
||
// instances à la fois (ex. chaque ligne d'un Répéteur utilisant ce modèle),
|
||
// toutes partageant le même element_id — les indexer par élément DOM réel
|
||
// évite qu'une nouvelle instance n'arrête l'animation d'une autre. Alimenté
|
||
// par deux entrées équivalentes — l'action de flow "jouer_animation_sprite"
|
||
// ci-dessus (événementiel) ET un clip de la Timeline d'animation de kind
|
||
// "sprite" (déclaratif, voir applyAnimationClip() dans screens.js) — les
|
||
// deux partagent ce même moteur, aucune logique dupliquée.
|
||
var activeSpriteAnimations = new Map();
|
||
|
||
// Phase 8 — résout le NOM d'une animation (ex. "walk") en liste de frames à
|
||
// partir du personnage ACTUELLEMENT assigné à cet élément
|
||
// (gameData.personnage_animations, reconstruit à chaque chargement de la
|
||
// page de jeu depuis _personnage_data — voir full_game_payload.py) plutôt
|
||
// que depuis des frames figées au moment où le créateur a configuré
|
||
// l'action/le clip : sans ça, changer le personnage Forge d'un élément
|
||
// dans l'éditeur n'avait aucun effet sur les animations déjà posées dans
|
||
// la logique de flow/la Timeline, qui continuaient à jouer indéfiniment
|
||
// les frames de l'ANCIEN personnage (bug signalé par l'utilisateur).
|
||
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||
// les nœuds/clips déjà créés avant ce changement.
|
||
function resolveSpriteFrames(elementId, spriteData) {
|
||
var byAnimation = (gameData.personnage_animations || {})[elementId];
|
||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||
}
|
||
return spriteData;
|
||
}
|
||
|
||
function runSpriteAnimation(targetEl, spriteData) {
|
||
var frames = spriteData.frames || [];
|
||
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||
var loop = !!spriteData.loop;
|
||
if (activeSpriteAnimations.has(targetEl)) {
|
||
clearInterval(activeSpriteAnimations.get(targetEl));
|
||
activeSpriteAnimations.delete(targetEl);
|
||
}
|
||
if (!frames.length) return;
|
||
var frameIndex = 0;
|
||
targetEl.src = frames[0];
|
||
if (frames.length === 1) return;
|
||
var intervalId = setInterval(function() {
|
||
frameIndex++;
|
||
if (frameIndex >= frames.length) {
|
||
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
|
||
frameIndex = 0;
|
||
}
|
||
targetEl.src = frames[frameIndex];
|
||
}, 1000 / fps);
|
||
activeSpriteAnimations.set(targetEl, intervalId);
|
||
}
|
||
|
||
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
|
||
// encapsule activeSpriteAnimations ici plutôt que de le manipuler
|
||
// directement depuis screens.js, même patron que runScreenTimerTriggers()
|
||
// dans triggers.js (l'appelant ne connaît jamais la structure interne).
|
||
function stopAllSpriteAnimations() {
|
||
activeSpriteAnimations.forEach(function(intervalId) { clearInterval(intervalId); });
|
||
activeSpriteAnimations.clear();
|
||
}
|