// ---------- 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. let activePersonnageControllerInterval = null; let 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). const FORGE_PERSONNAGE_FPS = 8; function forgePersonnageAnimExists(elementId, animName) { const byAnim = gameData.personnage_animations?.[elementId]; return !!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) { let width = screenData.scene_width; let height = screenData.scene_height; (screenData.elements || []).forEach((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, 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) { const worldEl = document.querySelector(`.playScreen[data-screen-id="${screenData.id}"] .sceneWorld`); if (!worldEl) return; const 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; } const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur'); const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`); const px = playerEl ? (Number.parseFloat(playerEl.style.left) || 0) + (Number.parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2; const py = playerEl ? (Number.parseFloat(playerEl.style.top) || 0) + (Number.parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2; const offsetX = Math.min( Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width), ); const 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; } } // Boîte de collision d'un objet à une position DONNÉE (pas forcément sa // position actuelle — voir forgeWouldCollide ci-dessous, qui teste une // position candidate AVANT de l'appliquer) — réutilise forgeCollisionRectFromBox // (conditions.js, déjà chargé avant ce fichier dans templates/play.html), // la MÊME formule que la condition de collision de flow (elementsOverlap). function forgeObjectCollisionRectAt(left, top, width, height, collision) { return forgeCollisionRectFromBox({ left, top, width, height, }, collision); } // Tout objet de scène potentiellement "solide" pour le déplacement au // clavier — jamais un "fond" (image de décor, voir add_scene_object.py : // pointer-events déjà désactivés au jeu, jamais un obstacle), ni un // objet dont la collision est explicitement désactivée, ni l'objet qui // se déplace lui-même. function forgeSolidObstacles(screenData, movingId) { return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && o.collision?.enabled !== false); } // Est-ce que le personnage, À la position candidate (newLeft/newTop), // chevaucherait la boîte de collision d'un AUTRE objet solide ? Bloque // alors le déplacement (voir forgeRunPersonnageControllerTick) — demandé // par l'utilisateur : "elle doit empêcher d'avancer en cas de collision". // Lit la position ACTUELLE des autres objets sur le DOM (pas // screenData.elements, jamais mis à jour après un premier déplacement — // voir actions.js, qui ne touche que le style, jamais les données) pour // rester correct même si un autre objet a déjà bougé. function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) { const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision); const obstacles = forgeSolidObstacles(screenData, movingObj.id); for (const other of obstacles) { const otherEl = document.querySelector(`[data-object-id="${other.id}"]`); if (!otherEl) continue; const otherLeft = Number.parseFloat(otherEl.style.left) || 0; const otherTop = Number.parseFloat(otherEl.style.top) || 0; const otherWidth = Number.parseFloat(otherEl.style.width) || 0; const otherHeight = Number.parseFloat(otherEl.style.height) || 0; const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision); if (forgeShapesOverlap(movingRect, otherRect)) return true; } return false; } // Déplacement BRUT tenté (avant collision), selon l'axe autorisé par cmd.axe // — extrait de forgeRunPersonnageControllerTick (lot 4 "modernisation JS", S3776). function forgeComputeMovementDelta(cmd) { let rawDx = 0; let rawDy = 0; if (cmd.axe !== 'vertical') { if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse; if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse; } if (cmd.axe !== 'horizontal') { if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse; if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse; } return { rawDx, rawDy }; } // Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un seul // est gêné) — glisser le long d'un mur en diagonale reste possible, plus // naturel qu'un blocage total au moindre contact. Extrait de // forgeRunPersonnageControllerTick. // `box` = {left, top, width, height} (position/taille ACTUELLES, avant // déplacement) ; `rawDelta` = {rawDx, rawDy} (voir forgeComputeMovementDelta) // — regroupés en objets pour rester sous la limite de 7 paramètres (S107), // plutôt que 9 paramètres positionnels distincts. function forgeResolveBlockedMovement(screenData, obj, targetEl, box, rawDelta) { const { left: curLeft, top: curTop, width: boxWidth, height: boxHeight, } = box; let { rawDx: dx, rawDy: dy } = rawDelta; if (dx !== 0) { const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx); if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0; } if (dy !== 0) { const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy); if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0; } return { dx, dy }; } // Anime idle (immobile) ou walk (déplacement effectif) — même fps pour les // deux (bug corrigé par l'utilisateur : idle tournait à 4 i/s contre 8 // pour walk, aucune raison réelle de les désynchroniser). Extrait de // forgeRunPersonnageControllerTick. function forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy) { if (dx === 0 && dy === 0) { const idleAnim = forgePersonnageIdleAnim(obj.id); if (idleAnim) { runSpriteAnimation( targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }), ); } return; } const moveAnim = forgePersonnageMovementAnim(obj.id); if (moveAnim) { runSpriteAnimation( targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }), ); } } // Déplace/anime UN personnage joueur pour ce tick — extrait du forEach de // forgeRunPersonnageControllerTick. function forgeRunPersonnagePlayerTick(screenData, obj) { // Seul le personnage "joueur" (voir screens/rendering/personnage_role.py) // est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant // qu'aucune logique de flow ne les pilote (voir add_scene_object.py). if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return; const cmd = obj.personnage_commandes; const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`); if (!targetEl) return; const { rawDx, rawDy } = forgeComputeMovementDelta(cmd); const curLeft = Number.parseFloat(targetEl.style.left) || 0; const curTop = Number.parseFloat(targetEl.style.top) || 0; const boxWidth = Number.parseFloat(targetEl.style.width) || 0; const boxHeight = Number.parseFloat(targetEl.style.height) || 0; const box = { left: curLeft, top: curTop, width: boxWidth, height: boxHeight, }; const { dx, dy } = forgeResolveBlockedMovement(screenData, obj, targetEl, box, { rawDx, rawDy }); // Se tourne vers la direction TENTÉE même si le déplacement est // finalement bloqué (fait face au mur/obstacle, plus naturel qu'un // personnage qui reste dos tourné contre ce qui le bloque). if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite'); if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx); if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy); forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy); } // 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((obj) => forgeRunPersonnagePlayerTick(screenData, obj)); } // 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'; const extraMatch = (cmd.extra || []).find((e) => 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((obj) => { if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return; const animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key); if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return; const 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?.kind !== 'jeu_2d') return; const elements = screenData.elements || []; const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur'); const hasFond = elements.some((o) => 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(() => { 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, forgeObjectCollisionRectAt, forgeSolidObstacles, forgeWouldCollide, }; // node:test charge ce fichier isolément (voir __tests__/) — jamais un //