// ---------- 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, }; }