Mouvement continu, limites de scène et priorité d'animation (jeu 2D)
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

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>
This commit is contained in:
william
2026-08-31 21:53:49 +02:00
co-authored by Claude Sonnet 5
parent c240181518
commit 727af55a5e
12 changed files with 377 additions and 10 deletions
+64 -7
View File
@@ -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,
};
}