diff --git a/screens/flow/constants.py b/screens/flow/constants.py index bb7bf9cd..b0ef6534 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -67,6 +67,22 @@ TRIGGER_EVENTS = [ # dans static/js/play/triggers.js), même patron de scan global que # "evenement" ci-dessus. ("clavier", "À l'appui sur une touche"), + # Mouvement continu (jeu 2D) — répond au manque signalé par + # l'utilisateur : "clavier" ci-dessus ne se déclenche qu'UNE FOIS par + # appui (keydown), insuffisant pour "maintenir une touche fait avancer + # le personnage en continu". "touche_maintenue" réutilise trigger_key + # et se déclenche À INTERVALLE RÉGULIER tant que cette touche reste + # enfoncée (voir runScreenHeldKeyTriggers() dans static/js/play/ + # triggers.js — même patron PAR ÉCRAN que "minuteur" ci-dessous, arrêté + # dès qu'on quitte l'écran ou relâche la touche), typiquement combiné à + # une action "Modifier un objet de scène → position relative" (avance + # le personnage à chaque tick) et "Jouer une animation" (marche/course + # — voir runSpriteAnimation() dans actions.js, qui ne redémarre plus + # une animation déjà en cours plutôt que de la faire repartir de zéro à + # chaque tick). "touche_relachee" (keyup, un seul déclenchement, scan + # global comme "clavier") permet de revenir à l'animation "idle". + ("touche_maintenue", "Tant qu'une touche est maintenue"), + ("touche_relachee", "Au relâchement d'une touche"), # Contrairement aux autres déclencheurs (réagissent à quelque chose), # celui-ci fait AVANCER le jeu tout seul, à intervalle régulier — # trigger_interval_ms (en millisecondes) est son seul réglage. Géré diff --git a/screens/labels/trigger_labels.py b/screens/labels/trigger_labels.py index 11a83882..1a9dab62 100644 --- a/screens/labels/trigger_labels.py +++ b/screens/labels/trigger_labels.py @@ -7,4 +7,6 @@ TRIGGER_LABELS = { "evenement": "Sur un événement personnalisé", "clavier": "À l'appui sur une touche", "minuteur": "Toutes les X millisecondes", + "touche_maintenue": "Tant qu'une touche est maintenue", + "touche_relachee": "Au relâchement d'une touche", } diff --git a/screens/scenes/flow_palette.py b/screens/scenes/flow_palette.py index 9df49eba..778ac0d3 100644 --- a/screens/scenes/flow_palette.py +++ b/screens/scenes/flow_palette.py @@ -10,7 +10,7 @@ # objet de scène. from ..flow.constants import TRIGGER_EVENTS as _ALL_TRIGGER_EVENTS -_SCENE_TRIGGER_KEYS = {"affichage", "evenement", "clavier", "minuteur"} +_SCENE_TRIGGER_KEYS = {"affichage", "evenement", "clavier", "minuteur", "touche_maintenue", "touche_relachee"} TRIGGER_EVENTS_2D = [(k, label) for k, label in _ALL_TRIGGER_EVENTS if k in _SCENE_TRIGGER_KEYS] ACTION_TYPE_LABELS_2D = { diff --git a/static/js/play/__tests__/actions.test.js b/static/js/play/__tests__/actions.test.js new file mode 100644 index 00000000..32a9e5b9 --- /dev/null +++ b/static/js/play/__tests__/actions.test.js @@ -0,0 +1,124 @@ +// Idempotence de runSpriteAnimation() — bug remonté par l'utilisateur : +// un déclencheur "touche_maintenue" (voir triggers.js) ré-exécute son +// graphe à chaque tick pendant que la touche reste enfoncée, donc une +// action "Jouer une animation" posée dessus est rappelée en continu — sans +// idempotence, chaque appel redémarrait l'animation à sa frame 0, la +// figeant visuellement au lieu de la faire boucler normalement. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +global.gameData = global.gameData || {}; +const { + resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations, + applyObjectProperty, clampSceneObjectPosition, +} = require('../actions.js'); + +function fakeImg() { + return { src: '' }; +} + +test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => { + const el = fakeImg(); + const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true }; + runSpriteAnimation(el, spriteData); + const firstIntervalId = activeSpriteAnimations.get(el).intervalId; + assert.equal(el.src, '/a0.png'); + + // Simule un "touche_maintenue" qui redemande la MÊME animation au tick suivant. + runSpriteAnimation(el, spriteData); + assert.equal(activeSpriteAnimations.get(el).intervalId, firstIntervalId, "l'intervalle ne doit pas être recréé"); + + stopAllSpriteAnimations(); +}); + +test('runSpriteAnimation — une animation looping différente redémarre bien', () => { + const el = fakeImg(); + runSpriteAnimation(el, { frames: ['/walk0.png', '/walk1.png'], fps: 8, loop: true }); + + // Une seule frame, non bouclée (ex. pose "jump" figée chez Kenney) : + // aucun intervalle à faire avancer, mais quand même tenue "en cours" un + // court instant (voir le test de priorité ci-dessous) — l'ancienne + // (walk, bouclée) doit avoir été arrêtée immédiatement. + runSpriteAnimation(el, { frames: ['/idle0.png'], fps: 8, loop: false }); + assert.equal(el.src, '/idle0.png'); + assert.notEqual(activeSpriteAnimations.get(el), undefined); + + stopAllSpriteAnimations(); + assert.equal(activeSpriteAnimations.size, 0); +}); + +// Bug remonté par l'utilisateur : "je ne peux pas me déplacer et sauter" +// — un déclencheur "touche_maintenue" (déplacement) redemande "marche" à +// chaque tick (~50ms). Si "sauter" (non bouclée) démarre PENDANT que la +// touche de déplacement reste enfoncée, la toute prochaine demande de +// "marche" écrasait aussitôt le saut avant qu'il ait pu s'afficher. +test('runSpriteAnimation — une animation non bouclée en cours résiste à une interruption par une AUTRE demande', (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + const el = fakeImg(); + const walk = { frames: ['/walk0.png', '/walk1.png', '/walk2.png'], fps: 10, loop: true }; + const jump = { frames: ['/jump0.png', '/jump1.png'], fps: 10, loop: false }; + + runSpriteAnimation(el, walk); + runSpriteAnimation(el, jump); // le joueur appuie sur "sauter" + assert.equal(el.src, '/jump0.png'); + + // Le déclencheur de déplacement, toujours actif, redemande "marche" — + // ne doit PAS interrompre le saut en cours. + runSpriteAnimation(el, walk); + assert.equal(el.src, '/jump0.png', "le saut ne doit pas être écrasé par 'marche' encore redemandée"); + + t.mock.timers.tick(200); // le cycle du saut (2 frames à 10 i/s, 100ms/frame) se termine + assert.equal(activeSpriteAnimations.get(el), undefined, 'le saut est terminé, plus rien ne le protège'); + + // "marche" reprend normalement une fois le saut terminé. + runSpriteAnimation(el, walk); + assert.notEqual(activeSpriteAnimations.get(el), undefined); + + stopAllSpriteAnimations(); +}); + +test('resolveSpriteFrames — résout depuis gameData.personnage_animations par nom', () => { + global.gameData = { personnage_animations: { '5': { walk: ['/w0.png', '/w1.png'] } } }; + const resolved = resolveSpriteFrames('5', { animation: 'walk', fps: 12, loop: true }); + assert.deepEqual(resolved.frames, ['/w0.png', '/w1.png']); + assert.equal(resolved.fps, 12); +}); + +// Bug remonté par l'utilisateur : un personnage déplacé en continu (touche +// maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne +// l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent +// désormais à [0, scene_width/height - sa propre taille]. +function withScreensData(screenData, fn) { + const previousScreens = global.screensData; + const previousId = global.window.currentScreenId; + global.screensData = [screenData]; + global.window.currentScreenId = screenData.id; + try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; } +} + +test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => { + withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => { + const el = { style: { width: '40px', height: '40px' } }; + assert.equal(clampSceneObjectPosition(el, 'left', 500), 160); // 200 - 40 + assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40 + }); +}); + +test('clampSceneObjectPosition — borne au bord gauche/haut (jamais négatif)', () => { + withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => { + const el = { style: { width: '40px', height: '40px' } }; + assert.equal(clampSceneObjectPosition(el, 'left', -50), 0); + assert.equal(clampSceneObjectPosition(el, 'top', -50), 0); + }); +}); + +test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène après plusieurs pas', () => { + withScreensData({ id: 1, scene_width: 100, scene_height: 100 }, () => { + const el = { style: { left: '90px', top: '0px', width: '20px', height: '20px' } }; + applyObjectProperty(el, 'pos_x_relatif', '5'); + applyObjectProperty(el, 'pos_x_relatif', '5'); + applyObjectProperty(el, 'pos_x_relatif', '5'); + assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà + }); +}); diff --git a/static/js/play/__tests__/triggers.test.js b/static/js/play/__tests__/triggers.test.js new file mode 100644 index 00000000..62614a45 --- /dev/null +++ b/static/js/play/__tests__/triggers.test.js @@ -0,0 +1,36 @@ +// isGameKey() — bug remonté par l'utilisateur ("je ne peux pas me déplacer +// et sauter" en même temps) : Espace/Flèches font défiler la page par +// défaut, et Espace "clique" en plus un bouton actuellement focus (souvent +// le bouton "Jouer" qui garde le focus après l'ouverture de l'aperçu) — +// ces comportements par défaut du navigateur pouvaient donner l'impression +// qu'une touche du jeu ne faisait rien. isGameKey() sert à décider quand +// appeler preventDefault() (voir bindKeyboardTriggers/bindHeldKeyTriggers). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +const { isGameKey } = require('../triggers.js'); + +test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/touche_maintenue/touche_relachee', () => { + global.gameData = { + flows: { + '1': { + nodes: [ + { id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ' }, + { id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight' }, + { id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft' }, + ], + edges: [], + }, + }, + }; + assert.equal(isGameKey(' '), true); + assert.equal(isGameKey('ArrowRight'), true); + assert.equal(isGameKey('ArrowLeft'), true); + assert.equal(isGameKey('Tab'), false); +}); + +test('isGameKey — ignore les touches d\'un autre type de déclencheur (ex. "Au clic")', () => { + global.gameData = { flows: { '1': { nodes: [{ id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5 }], edges: [] } } }; + assert.equal(isGameKey('Enter'), false); +}); diff --git a/static/js/play/actions.js b/static/js/play/actions.js index 8259fd08..d2c53355 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -124,6 +124,21 @@ function applyElementProperty(targetEl, property, value) { 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 @@ -136,12 +151,13 @@ function applyObjectProperty(targetEl, property, value) { const delta = parseFloat(value); if (isNaN(delta)) return; const current = parseFloat(targetEl.style[css]) || 0; - targetEl.style[css] = (current + delta) + 'px'; + targetEl.style[css] = clampSceneObjectPosition(targetEl, 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'; + const css = property === 'pos_x' ? 'left' : 'top'; + targetEl.style[css] = clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0) + 'px'; return; } applyElementProperty(targetEl, property, value); @@ -320,18 +336,51 @@ function resolveSpriteFrames(elementId, spriteData) { 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; - if (activeSpriteAnimations.has(targetEl)) { - clearInterval(activeSpriteAnimations.get(targetEl)); + 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) return; + 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) { @@ -340,7 +389,7 @@ function runSpriteAnimation(targetEl, spriteData) { } targetEl.src = frames[frameIndex]; }, 1000 / fps); - activeSpriteAnimations.set(targetEl, intervalId); + activeSpriteAnimations.set(targetEl, { intervalId: intervalId, signature: signature, loop: loop }); } // Appelée depuis showScreen() (screens.js) à chaque changement d'écran — @@ -348,6 +397,14 @@ function runSpriteAnimation(targetEl, spriteData) { // 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.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, + }; +} diff --git a/static/js/play/screens.js b/static/js/play/screens.js index c340b7f7..2f6af425 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -45,6 +45,7 @@ function showScreen(id) { stopAllSpriteAnimations(); runScreenShowTriggers(id); runScreenTimerTriggers(id); + runScreenHeldKeyTriggers(id); runAnimationTimeline(id); runScreenBackgroundMusic(id); } diff --git a/static/js/play/triggers.js b/static/js/play/triggers.js index e95b2d81..3563e6ef 100644 --- a/static/js/play/triggers.js +++ b/static/js/play/triggers.js @@ -161,8 +161,28 @@ function bindHoverTriggers() { // est parcouru à CHAQUE appui, pas une seule fois au chargement, pour // que les nœuds ajoutés/déplacés après un rafraîchissement des données // restent pris en compte sans devoir ré-attacher quoi que ce soit. +// Empêche le comportement par défaut du navigateur pour une touche que LE +// JEU écoute (ex. Espace/Flèches font défiler la page, et Espace "clique" +// en plus le bouton actuellement focus — souvent le bouton "Jouer" qui a +// gardé le focus après avoir ouvert l'aperçu) — sans ça, sauter et se +// déplacer en même temps pouvait sembler ne pas marcher : la touche +// réactivait/scrollait la page au lieu de se contenter de jouer le jeu. +// Jamais pour une touche qui n'est PAS écoutée par le jeu (laisse tel +// quel un champ de saisie ailleurs sur la page, par ex.). +function isGameKey(key) { + for (const screenIdKey in gameData.flows) { + if (gameData.flows[screenIdKey].nodes.some(function(n) { + return n.node_type === 'trigger' && + (n.trigger_event === 'clavier' || n.trigger_event === 'touche_maintenue' || n.trigger_event === 'touche_relachee') && + n.trigger_key === key; + })) return true; + } + return false; +} + function bindKeyboardTriggers() { window.addEventListener('keydown', function (ev) { + if (isGameKey(ev.key)) ev.preventDefault(); for (const screenIdKey in gameData.flows) { gameData.flows[screenIdKey].nodes .filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key; }) @@ -170,3 +190,58 @@ function bindKeyboardTriggers() { } }); } + +// Mouvement continu (jeu 2D) — répond au manque signalé par l'utilisateur : +// "clavier" ci-dessus ne se déclenche qu'UNE FOIS par appui (keydown), +// insuffisant pour "maintenir une touche fait avancer le personnage". +// heldKeys suit les touches actuellement enfoncées (Set, indépendant de la +// répétition OS du keydown — un keydown répété pour la même touche ne fait +// que la re-ajouter, sans effet). Un SEUL écouteur global (comme +// bindKeyboardTriggers ci-dessus), lu à intervalle régulier PAR ÉCRAN (voir +// runScreenHeldKeyTriggers, appelée depuis showScreen() comme +// runScreenTimerTriggers). "touche_relachee" (keyup), lui, n'a pas besoin +// d'être scopé par écran — un seul déclenchement ponctuel, même patron de +// scan global que "clavier". +var heldKeys = new Set(); + +function bindHeldKeyTriggers() { + window.addEventListener('keydown', function (ev) { + if (isGameKey(ev.key)) ev.preventDefault(); + heldKeys.add(ev.key); + }); + window.addEventListener('keyup', function (ev) { + heldKeys.delete(ev.key); + for (const screenIdKey in gameData.flows) { + gameData.flows[screenIdKey].nodes + .filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'touche_relachee' && n.trigger_key === ev.key; }) + .forEach(function(n){ runFlowFrom(n.id, screenIdKey); }); + } + }); + // La fenêtre peut perdre le focus touche encore enfoncée (alt-tab...) — + // sans ça, heldKeys garderait une touche "fantôme" indéfiniment maintenue. + window.addEventListener('blur', function () { heldKeys.clear(); }); +} + +// Même patron PAR ÉCRAN que runScreenTimerTriggers() ci-dessus : n'existe +// que tant que l'écran qui porte le déclencheur est affiché, réinitialisé +// à chaque changement d'écran (voir showScreen(), static/js/play/screens.js). +// 50ms (20 fois/seconde) : assez fluide pour un déplacement perçu comme +// continu, sans re-exécuter le graphe à une fréquence excessive. +var activeHeldKeyTriggerInterval = null; + +function runScreenHeldKeyTriggers(screenId) { + if (activeHeldKeyTriggerInterval) { clearInterval(activeHeldKeyTriggerInterval); activeHeldKeyTriggerInterval = null; } + const flow = gameData.flows[String(screenId)]; + if (!flow) return; + const nodes = flow.nodes.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'touche_maintenue'; }); + if (!nodes.length) return; + activeHeldKeyTriggerInterval = setInterval(function () { + nodes.filter(function(n){ return heldKeys.has(n.trigger_key); }) + .forEach(function(n){ runFlowFrom(n.id, screenId); }); + }, 50); +} + +// static/js/play/__tests__/ (node:test) — même patron que conditions.js. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { isGameKey, heldKeys }; +} diff --git a/static/js/screen_edit/animation-timeline.js b/static/js/screen_edit/animation-timeline.js index 4d143e17..f912b639 100644 --- a/static/js/screen_edit/animation-timeline.js +++ b/static/js/screen_edit/animation-timeline.js @@ -201,6 +201,10 @@ function populateAnimClipSpriteGrid(preselectedName) { grid.querySelectorAll('.iconGalleryTile').forEach(function(t) { t.classList.remove('selected'); }); tile.classList.add('selected'); nameField.value = animName; + // Vitesse par défaut proportionnelle au nombre d'images — voir le + // même correctif dans onSpriteTargetElementChange() (flow-editor.js). + var fpsField = document.getElementById('anim-clip-sprite-fps'); + if (fpsField) fpsField.value = Math.max(8, Math.min(24, frames.length)); }; grid.appendChild(tile); }); diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index 2562ab35..afb81796 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -54,6 +54,12 @@ function nodeLabel(node) { if (node.trigger_event === 'minuteur') { return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms'; } + if (node.trigger_event === 'touche_maintenue') { + return '⌨️⏳ Touche maintenue « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »'; + } + if (node.trigger_event === 'touche_relachee') { + return '⌨️⬆️ Relâchement de « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »'; + } return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' + (ELEMENTS_LABELS[node.trigger_element_id || node.trigger_object_id] || '?'); } @@ -330,7 +336,9 @@ function toggleFlowTriggerFields() { // play.html), le seul réglage propre à ce nœud est QUEL événement. var isCustomEvent = eventVal === 'evenement'; // Idem "clavier"/"minuteur" (Phase 3) : ni élément ni écran précis. - var isKeyboard = eventVal === 'clavier'; + // "touche_maintenue"/"touche_relachee" (mouvement continu) réutilisent + // le même champ trigger_key que "clavier". + var isKeyboard = eventVal === 'clavier' || eventVal === 'touche_maintenue' || eventVal === 'touche_relachee'; var isTimer = eventVal === 'minuteur'; var noElement = isAffichage || isCustomEvent || isKeyboard || isTimer; document.getElementById('nf-trigger-element-row').style.display = noElement ? 'none' : 'block'; @@ -618,6 +626,13 @@ function onSpriteTargetElementChange() { grid.querySelectorAll('.iconGalleryTile').forEach(function(t) { t.classList.remove('selected'); }); tile.classList.add('selected'); nameField.value = animName; + // Vitesse par défaut proportionnelle au nombre d'images (8 pour un + // cycle Kenney à 8 images = ~1 cycle/seconde, mais un cycle CraftPix + // à 30 images (personnages animaux) au même 8 i/s prenait ~3,75 s — + // visiblement "très lent", signalé par l'utilisateur. Reste modifiable + // à la main ensuite (ce n'est qu'une valeur de départ raisonnable). + var fpsField = document.getElementById('nf-action-sprite-fps'); + if (fpsField) fpsField.value = Math.max(8, Math.min(24, frames.length)); }; grid.appendChild(tile); }); @@ -683,7 +698,7 @@ function submitNodeForm() { payload.trigger_event = document.getElementById('nf-trigger-event').value; if (payload.trigger_event === 'evenement') { payload.trigger_custom_event_id = parseInt(document.getElementById('nf-trigger-custom-event').value, 10); - } else if (payload.trigger_event === 'clavier') { + } else if (payload.trigger_event === 'clavier' || payload.trigger_event === 'touche_maintenue' || payload.trigger_event === 'touche_relachee') { payload.trigger_key = document.getElementById('nf-trigger-key').value; } else if (payload.trigger_event === 'minuteur') { payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10); diff --git a/templates/play.html b/templates/play.html index aa097d95..c4c7913d 100644 --- a/templates/play.html +++ b/templates/play.html @@ -147,6 +147,10 @@ // ci-dessus a déjà démarré les minuteurs de l'écran affiché au premier // affichage (runScreenTimerTriggers, appelé depuis showScreen()). bindKeyboardTriggers(); + // Mouvement continu (touche maintenue/relâchée) — même principe, voir + // triggers.js. runScreenHeldKeyTriggers(), lui, est déjà appelé depuis + // showScreen() comme runScreenTimerTriggers(). + bindHeldKeyTriggers(); diff --git a/tests/test_scene_edit_view.py b/tests/test_scene_edit_view.py index 0c052746..5a28fbbf 100644 --- a/tests/test_scene_edit_view.py +++ b/tests/test_scene_edit_view.py @@ -90,6 +90,26 @@ def test_flow_node_targets_scene_object(client, tmp_game_slug_cleanup): assert node["target_element_id"] is None +def test_held_key_trigger_persists_and_is_in_scene_palette(client, tmp_game_slug_cleanup): + """Mouvement continu (touche maintenue) — répond au manque signalé par + l'utilisateur : "clavier" (keydown) ne se déclenche qu'une fois par + appui, insuffisant pour faire avancer un personnage en continu tant + qu'une touche reste enfoncée. Voir runScreenHeldKeyTriggers() + (static/js/play/triggers.js) et TRIGGER_EVENTS (screens/flow/constants.py).""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + assert "touche_maintenue" in dict(screens.TRIGGER_EVENTS_2D) + assert "touche_relachee" in dict(screens.TRIGGER_EVENTS_2D) + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({"node_type": "trigger", "trigger_event": "touche_maintenue", "trigger_key": "ArrowRight"}), + content_type="application/json", + ) + assert resp.status_code == 200 + node = screens.get_flow_node(slug, resp.get_json()["id"]) + assert node["trigger_event"] == "touche_maintenue" + assert node["trigger_key"] == "ArrowRight" + + def test_flow_node_collision_condition_targets_scene_objects(client, tmp_game_slug_cleanup): slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) obj_a = screens.add_scene_object(slug, screen_id) @@ -127,6 +147,19 @@ def test_play_html_renders_fixed_size_scene(client, tmp_game_slug_cleanup): assert f"width:{db.game_meta(slug)['name'] and 960}px" in html or "width:960px" in html +def test_render_scene_object_carries_data_anim_target(client, tmp_game_slug_cleanup): + """Bug remonté par l'utilisateur : "les animations ne se jouent pas + dans l'aperçu" — applyAnimationClip() (static/js/play/screens.js) et + animation-timeline.js sélectionnent TOUJOURS leur cible via + [data-anim-target=...], jamais data-element-id/data-object-id. Sans cet + attribut sur l'objet de scène, AUCUN clip de Timeline (sprite, Animate.css + ou personnalisé) ne trouvait jamais sa cible.""" + slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) + obj_id = screens.add_scene_object(slug, screen_id) + html = screens.render_scene_object(screens.get_scene_object(slug, obj_id)) + assert f'data-anim-target="{obj_id}"' in html + + def test_screen_edit_js_bundle_contains_scene_flow_helpers(): js = screen_edit_js_bundle() assert "FLOW_TARGETS_OBJECTS" in js