Deux retours utilisateur sur la boîte de collision (ajoutée précédemment) : - Visible et réglable directement dans l'éditeur de scène : pour l'objet sélectionné, un contour pointillé (rectangle/cercle) se superpose sur le canevas — glisser son corps ajuste le décalage, glisser sa poignée (coin bas-droit) ajuste la taille, les deux se répercutent dans le panneau "🧱 Collision" et inversement (édition des champs -> aperçu à jour). static/js/scenes/scene-editor.js::onCollisionBoxMouseDown/ onCollisionBoxResizeMouseDown, mirror des poignées de position/taille déjà existantes pour l'objet lui-même. - Bloque désormais RÉELLEMENT le déplacement au clavier : avant chaque pas, personnage-controller.js teste si la position candidate chevaucherait la boîte de collision d'un autre objet solide (jamais un "fond", jamais un objet à collision désactivée) et annule ce pas — par AXE séparément, pour permettre de glisser le long d'un mur en diagonale plutôt qu'un blocage total au moindre contact. Réutilise forgeShapesOverlap (conditions.js, factorisé depuis elementsOverlap pour ne jamais dupliquer la règle "qu'est-ce qui se touche"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
270 lines
14 KiB
JavaScript
270 lines
14 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' && o.personnage_role === 'joueur'; });
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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: left, top: top, width: width, height: 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(function (o) {
|
|
return o.id !== movingId && o.kind !== 'fond' && !(o.collision && 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 (let i = 0; i < obstacles.length; i++) {
|
|
const other = obstacles[i];
|
|
const otherEl = document.querySelector('[data-object-id="' + other.id + '"]');
|
|
if (!otherEl) continue;
|
|
const otherLeft = parseFloat(otherEl.style.left) || 0;
|
|
const otherTop = parseFloat(otherEl.style.top) || 0;
|
|
const otherWidth = parseFloat(otherEl.style.width) || 0;
|
|
const otherHeight = parseFloat(otherEl.style.height) || 0;
|
|
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
|
|
if (forgeShapesOverlap(movingRect, otherRect)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 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) {
|
|
// 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;
|
|
var cmd = obj.personnage_commandes;
|
|
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
|
if (!targetEl) return;
|
|
|
|
var rawDx = 0, 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;
|
|
}
|
|
|
|
// 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.
|
|
var curLeft = parseFloat(targetEl.style.left) || 0;
|
|
var curTop = parseFloat(targetEl.style.top) || 0;
|
|
var boxWidth = parseFloat(targetEl.style.width) || 0;
|
|
var boxHeight = parseFloat(targetEl.style.height) || 0;
|
|
var dx = rawDx, dy = rawDy;
|
|
if (dx !== 0) {
|
|
var candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
|
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
|
}
|
|
if (dy !== 0) {
|
|
var candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
|
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
|
}
|
|
// 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');
|
|
|
|
// 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, '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_role !== 'joueur' || !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' && o.personnage_role === 'joueur'; });
|
|
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,
|
|
forgeObjectCollisionRectAt, forgeSolidObstacles, forgeWouldCollide,
|
|
};
|
|
// node:test charge ce fichier isolément (voir __tests__/) — jamais un
|
|
// <script> séparé en navigateur, où conditions.js est déjà chargé avant
|
|
// celui-ci (voir templates/play.html).
|
|
const conditionsMod = require('./conditions.js');
|
|
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
|
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
|
}
|