Files
Forge-Engine/static/js/play/actions.js
T
williamandClaude Sonnet 5 727af55a5e
Build and deploy / test-python (push) Failing after 1m46s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Mouvement continu, limites de scène et priorité d'animation (jeu 2D)
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>
2026-08-31 21:53:49 +02:00

411 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- 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;
}
// Bloque un objet de scène dans les limites de LA scène affichée (bug
// signalé par l'utilisateur : un déplacement continu au clavier — voir
// triggers.js/"touche_maintenue" — pouvait le faire sortir du cadre sans
// rien pour l'en empêcher). scene_width/scene_height viennent de
// screensData (voir full_game_payload.py, _screens.scene_width/height) ;
// sans écran trouvé (jamais censé arriver pour un objet de scène), la
// valeur n'est pas bornée plutôt que de risquer un blocage à 0.
function clampSceneObjectPosition(targetEl, css, rawValue) {
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
if (!screenData || screenData.scene_width == null) return rawValue;
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
const max = (css === 'left' ? screenData.scene_width : screenData.scene_height) - size;
return Math.max(0, Math.min(max, rawValue));
}
// 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] = clampSceneObjectPosition(targetEl, css, current + delta) + 'px';
return;
}
if (property === 'pos_x' || property === 'pos_y') {
if (value === undefined || value === null) return;
const css = property === 'pos_x' ? 'left' : 'top';
targetEl.style[css] = clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0) + '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;
}
// Mouvement continu (touche maintenue, voir triggers.js) — un déclencheur
// "touche_maintenue" ré-exécute tout son graphe à CHAQUE tick pendant que
// la touche reste enfoncée, donc une action "Jouer une animation" posée
// dessus (ex. "marche") était rappelée des dizaines de fois par seconde :
// sans idempotence, chaque appel redémarrait l'animation à sa frame 0
// (clearInterval + reset), la figeant visuellement au lieu de la faire
// boucler normalement. Un appel qui demande la MÊME animation (mêmes
// frames/fps/boucle) qu'une animation déjà active sur cette cible ne fait
// donc plus rien — la laisse simplement continuer son cycle en cours.
//
// PRIORITÉ (bug signalé — "je ne peux pas me déplacer et sauter") : si le
// joueur maintient une touche de déplacement (donc "marche" redemandée en
// boucle, toutes les ~50ms) et appuie sur "sauter" PENDANT ce temps, le
// saut démarrait bien mais était écrasé par la toute prochaine demande de
// "marche" avant d'avoir eu le temps de s'afficher. Une animation NON
// BOUCLÉE actuellement active (typiquement une action ponctuelle — sauter,
// attaquer — par opposition à marche/idle qui bouclent en continu) va
// désormais AU BOUT avant qu'une autre demande, même différente, puisse
// l'interrompre. Vrai aussi pour une pose à UNE SEULE frame (ex. "jump"
// chez Kenney, une pose figée plutôt qu'un cycle) : sans minuterie propre
// à faire avancer des frames, elle serait resortie de activeSpriteAnimations
// aussitôt posée si on ne la protégeait pas explicitement — un setTimeout
// dédié la maintient "en cours" un court instant avant de la libérer.
function runSpriteAnimation(targetEl, spriteData) {
var frames = spriteData.frames || [];
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
var loop = !!spriteData.loop;
var signature = frames.join('|') + '@' + fps + (loop ? '+loop' : '');
var active = activeSpriteAnimations.get(targetEl);
if (active && active.signature === signature) return;
if (active && !active.loop) return;
if (active) {
clearInterval(active.intervalId);
activeSpriteAnimations.delete(targetEl);
}
if (!frames.length) return;
var frameIndex = 0;
targetEl.src = frames[0];
if (frames.length === 1) {
if (loop) return;
var holdMs = Math.max(1000 / fps, 150);
var timeoutId = setTimeout(function() { activeSpriteAnimations.delete(targetEl); }, holdMs);
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature: signature, loop: loop });
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: intervalId, signature: signature, loop: loop });
}
// 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(active) { clearInterval(active.intervalId); });
activeSpriteAnimations.clear();
}
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
applyObjectProperty, clampSceneObjectPosition,
};
}