Déplacement/animation automatiques d'un personnage (scène 2D)
Simplification demandée pour l'éditeur de jeu RPG : un personnage posé sur une scène 2D se déplace et s'anime TOUT SEUL avec ZQSD (+ E pour interagir) dès qu'il est posé — plus besoin de poser le moindre nœud de flow pour un mouvement de base. Le panneau "🕹️ Commandes" du personnage permet de remapper les 4 touches de déplacement et la touche d'interaction, et de bloquer un axe (horizontal/vertical seulement). Entièrement client (static/js/play/personnage-controller.js), réutilise heldKeys (triggers.js) et applyObjectProperty/clampSceneObjectPosition/ runSpriteAnimation (actions.js) — aucune logique dupliquée, et ça fonctionne aussi bien en ligne que dans l'export Web/SCORM (aucune requête serveur). "walk"/"idle"/"interact" (poses déjà présentes pour tout personnage Forge/CraftPix) sont utilisées telles quelles ; une pose absente dégrade silencieusement (déplacement sans animation) plutôt que de planter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e82eb7ce88
commit
74f7ca396f
@@ -0,0 +1,123 @@
|
||||
// Déplacement/animation automatiques d'un personnage (voir
|
||||
// static/js/play/personnage-controller.js) : ZQSD + E par défaut, sans
|
||||
// le moindre nœud de flow à poser — réutilise applyObjectProperty/
|
||||
// clampSceneObjectPosition/runSpriteAnimation/resolveSpriteFrames
|
||||
// (actions.js), heldKeys (triggers.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
|
||||
const {
|
||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim,
|
||||
forgeRunPersonnageControllerTick,
|
||||
} = require('../personnage-controller.js');
|
||||
|
||||
function fakeSceneEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
style: { left: '100px', top: '100px', width: '20px', height: '20px' },
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: c => (classes.has(c) ? classes.delete(c) : classes.add(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withPersonnageObject(obj, elByObjectId, fn) {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => elByObjectId[sel.match(/"(\d+)"/)[1]] };
|
||||
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
|
||||
global.window.currentScreenId = 1;
|
||||
global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } };
|
||||
try { fn(); } finally { global.document = previousDoc; stopAllSpriteAnimations(); }
|
||||
}
|
||||
const { stopAllSpriteAnimations } = actionsMod;
|
||||
|
||||
test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux si la pose est absente', () => {
|
||||
global.gameData = { personnage_animations: { 5: { idle: ['/i0.png'] } } };
|
||||
assert.equal(forgePersonnageMovementAnim(5), null); // pas de "walk" pour ce personnage
|
||||
assert.equal(forgePersonnageIdleAnim(5), 'idle');
|
||||
assert.equal(forgePersonnageAnimExists(5, 'interact'), false);
|
||||
});
|
||||
|
||||
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
|
||||
const obj = { id: 10, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 10: el }, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
forgeRunPersonnageControllerTick({ elements: [obj] });
|
||||
assert.equal(el.style.left, '104px');
|
||||
assert.equal(el.classList.contains('forgeFlipped'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
|
||||
const obj = { id: 11, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 11: el }, () => {
|
||||
global.heldKeys = new Set(['q']);
|
||||
forgeRunPersonnageControllerTick({ elements: [obj] });
|
||||
assert.equal(el.style.left, '96px');
|
||||
assert.equal(el.classList.contains('forgeFlipped'), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
|
||||
const obj = { id: 12, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 12: el }, () => {
|
||||
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
|
||||
forgeRunPersonnageControllerTick({ elements: [obj] });
|
||||
assert.equal(el.style.top, '100px'); // inchangé
|
||||
});
|
||||
});
|
||||
|
||||
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
|
||||
const obj = { id: 13, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 13: el }, () => {
|
||||
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
|
||||
forgeRunPersonnageControllerTick({ elements: [obj] });
|
||||
assert.equal(el.style.left, '100px'); // inchangé
|
||||
});
|
||||
});
|
||||
|
||||
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
|
||||
const obj = { id: 14, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
|
||||
const el = fakeSceneEl();
|
||||
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
|
||||
withPersonnageObject(obj, { 14: el }, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
forgeRunPersonnageControllerTick({ elements: [obj] });
|
||||
assert.equal(el.style.left, '480px');
|
||||
});
|
||||
});
|
||||
|
||||
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
|
||||
const obj = { id: 15, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 15: el }, () => {
|
||||
global.heldKeys = new Set();
|
||||
forgeRunPersonnageControllerTick({ elements: [obj] });
|
||||
assert.equal(el.style.left, '100px');
|
||||
assert.equal(el.style.top, '100px');
|
||||
});
|
||||
});
|
||||
|
||||
test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans commandes résolues', () => {
|
||||
const decor = { id: 16, kind: 'decor' };
|
||||
withPersonnageObject(decor, {}, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
// Ne doit pas lever (aucun élément DOM trouvé pour cet objet, mais on
|
||||
// ne devrait même pas tenter de le chercher vu qu'il n'est pas un personnage).
|
||||
assert.doesNotThrow(() => forgeRunPersonnageControllerTick({ elements: [decor] }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// ---------- Personnage jouable — déplacement/animation automatiques ----------
|
||||
// N'importe quel objet de scène kind="personnage" se déplace et s'anime
|
||||
// TOUT SEUL avec ses touches (ZQSD + E par défaut — voir "Commandes" du
|
||||
// panneau de propriétés, templates/scene_edit.html) dès qu'il est posé sur
|
||||
// la scène, sans le moindre nœud de flow à poser — contrairement au reste
|
||||
// du moteur, entièrement piloté par le graphe de logique. Réutilise
|
||||
// heldKeys (déjà tenu à jour par bindHeldKeyTriggers, triggers.js) pour
|
||||
// savoir quelles touches sont enfoncées, et
|
||||
// applyObjectProperty/clampSceneObjectPosition/runSpriteAnimation/
|
||||
// resolveSpriteFrames (actions.js) pour le déplacement/l'animation — même
|
||||
// mécanique que "Modifier un objet de scène"/"Jouer une animation",
|
||||
// jamais dupliquée.
|
||||
|
||||
var activePersonnageControllerInterval = null;
|
||||
var activePersonnageInteractListener = null;
|
||||
|
||||
function forgePersonnageAnimExists(elementId, animName) {
|
||||
var byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
||||
}
|
||||
|
||||
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
|
||||
// (voir screens/labels/sprite_library.py — aucune variante directionnelle
|
||||
// walk_haut/walk_bas...) : le sens se voit via l'inversion miroir
|
||||
// (orientation, déjà existante — Phase 7), pas une frame différente.
|
||||
// Absente (personnage sans cette pose) : le déplacement continue sans
|
||||
// animation plutôt que de planter, comme le reste du moteur.
|
||||
function forgePersonnageMovementAnim(elementId) {
|
||||
return forgePersonnageAnimExists(elementId, 'walk') ? 'walk' : null;
|
||||
}
|
||||
|
||||
function forgePersonnageIdleAnim(elementId) {
|
||||
return forgePersonnageAnimExists(elementId, 'idle') ? 'idle' : null;
|
||||
}
|
||||
|
||||
function forgeStopPersonnageControllers() {
|
||||
if (activePersonnageControllerInterval) {
|
||||
clearInterval(activePersonnageControllerInterval);
|
||||
activePersonnageControllerInterval = null;
|
||||
}
|
||||
if (activePersonnageInteractListener) {
|
||||
window.removeEventListener('keydown', activePersonnageInteractListener);
|
||||
activePersonnageInteractListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||
// (triggers.js), pour un déplacement perçu comme continu.
|
||||
function forgeRunPersonnageControllerTick(screenData) {
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
|
||||
var cmd = obj.personnage_commandes;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
if (!targetEl) return;
|
||||
|
||||
var dx = 0, dy = 0;
|
||||
if (cmd.axe !== 'vertical') {
|
||||
if (heldKeys.has(cmd.gauche)) dx -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.droite)) dx += cmd.vitesse;
|
||||
}
|
||||
if (cmd.axe !== 'horizontal') {
|
||||
if (heldKeys.has(cmd.haut)) dy -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.bas)) dy += cmd.vitesse;
|
||||
}
|
||||
|
||||
if (dx === 0 && dy === 0) {
|
||||
var idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: 4, loop: true }));
|
||||
return;
|
||||
}
|
||||
if (dx !== 0) {
|
||||
applyObjectProperty(targetEl, 'orientation', dx < 0 ? 'gauche' : 'droite');
|
||||
applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||
}
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
var moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: 8, loop: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// Ponctuel (keydown, pas heldKeys) — contrairement au déplacement,
|
||||
// "interagir" est une action instantanée (ouvrir un coffre, parler à un
|
||||
// PNJ...), jamais un mouvement continu.
|
||||
function forgeStartPersonnageInteractListener(screenData) {
|
||||
activePersonnageInteractListener = function (ev) {
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
|
||||
if (obj.personnage_commandes.interagir !== ev.key) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
if (!targetEl) return;
|
||||
var interactAnim = forgePersonnageAnimExists(obj.id, 'interact') ? 'interact' : null;
|
||||
if (interactAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: interactAnim, fps: 8, loop: false }));
|
||||
});
|
||||
};
|
||||
window.addEventListener('keydown', activePersonnageInteractListener);
|
||||
}
|
||||
|
||||
// Même patron PAR ÉCRAN que runScreenHeldKeyTriggers/runScreenTimerTriggers
|
||||
// (triggers.js/screens.js) : n'existe que tant que l'écran est affiché,
|
||||
// réinitialisé à chaque changement d'écran (voir showScreen()).
|
||||
function forgeStartPersonnageControllers(screenData) {
|
||||
forgeStopPersonnageControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var hasPersonnage = (screenData.elements || []).some(function (o) { return o.kind === 'personnage'; });
|
||||
if (!hasPersonnage) return;
|
||||
activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50);
|
||||
forgeStartPersonnageInteractListener(screenData);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — pas de risque en navigateur :
|
||||
// `module` n'existe pas là-bas, cette branche ne s'exécute jamais côté
|
||||
// client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim,
|
||||
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
||||
forgeStartPersonnageInteractListener,
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,10 @@ function showScreen(id) {
|
||||
runScreenHeldKeyTriggers(id);
|
||||
runAnimationTimeline(id);
|
||||
runScreenBackgroundMusic(id);
|
||||
// Déplacement/animation automatiques de tout personnage posé sur cet
|
||||
// écran (voir static/js/play/personnage-controller.js) — n'a d'effet
|
||||
// que sur un écran "jeu_2d" contenant au moins un objet kind="personnage".
|
||||
forgeStartPersonnageControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
}
|
||||
|
||||
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
|
||||
|
||||
@@ -143,3 +143,35 @@ function swapSceneObjectCharacter(forgeCharacter) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// "Commandes" (voir static/js/play/personnage-controller.js) : touches de
|
||||
// déplacement/interaction + blocage d'axe d'un personnage — un seul
|
||||
// formulaire, enregistré en AJAX à chaque changement de champ, même
|
||||
// patron que swapSceneObjectCharacter() ci-dessus (POST JSON vers
|
||||
// routes/scenes/scene_object_commands.py, aucun rechargement de page).
|
||||
function saveSceneObjectCommands() {
|
||||
var body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/commandes', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
haut: document.getElementById('cmd-haut').value,
|
||||
bas: document.getElementById('cmd-bas').value,
|
||||
gauche: document.getElementById('cmd-gauche').value,
|
||||
droite: document.getElementById('cmd-droite').value,
|
||||
interagir: document.getElementById('cmd-interagir').value,
|
||||
axe: document.getElementById('cmd-axe').value,
|
||||
vitesse: document.getElementById('cmd-vitesse').value,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
(function () {
|
||||
var body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir', 'cmd-axe', 'cmd-vitesse'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', saveSceneObjectCommands);
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user