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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c240181518
commit
727af55a5e
@@ -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à
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user