Trois retours utilisateur : - fps d'idle/interagir/touches supplémentaires alignés sur celui de la marche (8 i/s partout, était 4 pour idle — perçu comme "les autres animations sont lentes à côté de la marche"). - Nouveau menu "🏞️ Images de fond" dans "Objets de cette scène" : pose un objet kind="fond" à la taille RÉELLE de l'image choisie (pack CraftPix intégré en galerie, admin seulement — licence, voir core/sprite_gate.py), derrière tout le reste, insensible au clic. - Si ce fond dépasse la scène, elle devient le "monde" : .playScreen. playScene est désormais le viewport (overflow:hidden, taille fixe), .sceneWorld le monde à l'intérieur — la caméra centre le premier personnage trouvé, bornée pour ne jamais montrer au-delà des bords (voir personnage-controller.js::forgeUpdateSceneCamera). Le personnage peut désormais se déplacer sur tout le monde, pas seulement le petit cadre visible (clampSceneObjectPosition, actions.js). Un jeu sans fond XXL garde un comportement strictement identique à avant (monde == scène, transform vide). Bibliothèque générée une fois par scripts/generate_background_manifest.py (assets/background/, non versionné, licence CraftPix) vers static/backgrounds/ (committé), même patron que generate_animal_sprite_manifest.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197 lines
10 KiB
JavaScript
197 lines
10 KiB
JavaScript
// ---------- 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;
|
|
|
|
// Un seul rythme pour TOUTES les poses (marche, idle, interagir, touches
|
|
// supplémentaires) — les désynchroniser (ex. idle plus lent que walk)
|
|
// n'a aucune justification et se voit immédiatement au jeu (bug signalé
|
|
// par l'utilisateur).
|
|
var FORGE_PERSONNAGE_FPS = 8;
|
|
|
|
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;
|
|
}
|
|
|
|
// Taille du "monde" (voir static/style.css .sceneWorld) : la scène elle-
|
|
// même par défaut, agrandie si un objet kind="fond" (voir
|
|
// add_scene_object.py) dépasse ses bords — un jeu sans fond XXL garde
|
|
// donc EXACTEMENT son comportement d'avant (monde == scène). Mémorisée
|
|
// directement sur screenData (déjà l'objet partagé avec screensData,
|
|
// voir static/js/play/actions.js) pour que clampSceneObjectPosition
|
|
// borne un déplacement au monde entier, pas seulement au petit cadre
|
|
// visible de la scène.
|
|
function forgeUpdateSceneWorldSize(screenData) {
|
|
var width = screenData.scene_width, height = screenData.scene_height;
|
|
(screenData.elements || []).forEach(function (o) {
|
|
if (o.kind !== 'fond') return;
|
|
width = Math.max(width, (o.x || 0) + (o.width || 0));
|
|
height = Math.max(height, (o.y || 0) + (o.height || 0));
|
|
});
|
|
screenData.world_width = width;
|
|
screenData.world_height = height;
|
|
return { width: width, height: height };
|
|
}
|
|
|
|
// Caméra qui suit le personnage (le premier objet kind="personnage"
|
|
// trouvé — aucune notion de "joueur" distincte n'existe ailleurs dans le
|
|
// moteur 2D, voir personnage-controller.js dans son ensemble) : centre le
|
|
// monde sur lui, borné pour ne jamais montrer au-delà de ses bords (même
|
|
// principe que clampSceneObjectPosition, actions.js, mais pour la caméra
|
|
// plutôt qu'un objet). Sans effet (transform vidé) tant que le monde ne
|
|
// dépasse pas la scène — c'est ce qui garde le comportement identique à
|
|
// avant pour tout jeu sans fond plus grand que sa scène.
|
|
function forgeUpdateSceneCamera(screenData) {
|
|
var worldEl = document.querySelector('.playScreen[data-screen-id="' + screenData.id + '"] .sceneWorld');
|
|
if (!worldEl) return;
|
|
var world = forgeUpdateSceneWorldSize(screenData);
|
|
worldEl.style.width = world.width + 'px';
|
|
worldEl.style.height = world.height + 'px';
|
|
if (world.width <= screenData.scene_width && world.height <= screenData.scene_height) {
|
|
worldEl.style.transform = '';
|
|
return;
|
|
}
|
|
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage'; });
|
|
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
|
|
var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2;
|
|
var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2;
|
|
var offsetX = Math.min(Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width));
|
|
var offsetY = Math.min(Math.max(py - screenData.scene_height / 2, 0), Math.max(0, world.height - screenData.scene_height));
|
|
worldEl.style.transform = 'translate(' + (-offsetX) + 'px, ' + (-offsetY) + 'px)';
|
|
}
|
|
|
|
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) {
|
|
forgeUpdateSceneCamera(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;
|
|
}
|
|
|
|
// Même fps pour idle et walk (bug signalé par l'utilisateur : idle
|
|
// tournait à 4 i/s contre 8 pour walk, perçue comme "les autres
|
|
// animations sont lentes à côté de la marche" — aucune raison réelle
|
|
// de les désynchroniser).
|
|
if (dx === 0 && dy === 0) {
|
|
var idleAnim = forgePersonnageIdleAnim(obj.id);
|
|
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, 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: FORGE_PERSONNAGE_FPS, loop: true }));
|
|
});
|
|
}
|
|
|
|
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
|
|
// "interagir" (usage fixe, pose "interact") d'abord, sinon la première
|
|
// touche supplémentaire (cmd.extra, voir "Animations supplémentaires" du
|
|
// panneau Commandes) qui correspond, pour un nombre de poses illimité
|
|
// (sauter, attaquer, courir...) sans ajouter un champ fixe par pose.
|
|
function forgePersonnageActionAnim(cmd, key) {
|
|
if (cmd.interagir === key) return 'interact';
|
|
var extraMatch = (cmd.extra || []).find(function (e) { return e.touche === key; });
|
|
return extraMatch ? extraMatch.animation : null;
|
|
}
|
|
|
|
// Ponctuel (keydown, pas heldKeys) — contrairement au déplacement, une
|
|
// touche d'action est une action instantanée (interagir, sauter,
|
|
// attaquer...), jamais un mouvement continu.
|
|
function forgeStartPersonnageInteractListener(screenData) {
|
|
activePersonnageInteractListener = function (ev) {
|
|
(screenData.elements || []).forEach(function (obj) {
|
|
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
|
|
var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
|
|
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
|
|
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
|
if (!targetEl) return;
|
|
runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, 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 elements = screenData.elements || [];
|
|
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage'; });
|
|
var hasFond = elements.some(function (o) { return o.kind === 'fond'; });
|
|
// Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms
|
|
// plus tard) pour éviter un flash d'une frame à la taille/caméra par
|
|
// défaut. Un fond seul (sans personnage) n'a besoin que de CET appel
|
|
// ponctuel — rien à suivre en continu ; un personnage, lui, a besoin du
|
|
// tick régulier pour le déplacement continu au clavier.
|
|
if (hasFond) forgeUpdateSceneCamera(screenData);
|
|
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, forgePersonnageActionAnim,
|
|
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
|
forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
|
};
|
|
}
|