diff --git a/static/js/play/__tests__/conditions.test.js b/static/js/play/__tests__/conditions.test.js index 32edf2a1..aa4be5fc 100644 --- a/static/js/play/__tests__/conditions.test.js +++ b/static/js/play/__tests__/conditions.test.js @@ -16,6 +16,7 @@ const { forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, + forgeShapesOverlap, } = require('../conditions.js'); function withGameData(data, fn) { @@ -127,19 +128,19 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', // forme, activée/désactivée). test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => { const box = { left: 10, top: 20, width: 40, height: 60 }; - assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60 }); + assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle' }); }); test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => { const box = { left: 0, top: 0, width: 100, height: 100 }; const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 }); - assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20 }); + assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle' }); }); test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => { const box = { left: 0, top: 0, width: 100, height: 100 }; const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 }); - assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20 }); + assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle' }); }); test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => { @@ -154,3 +155,17 @@ test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => { assert.equal(forgeCirclesOverlap(a, touching), true); assert.equal(forgeCirclesOverlap(a, apart), false); }); + +test('forgeShapesOverlap — deux cercles utilisent le test cercle-cercle', () => { + const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle' }; + const b = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle' }; + const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle' }; + assert.equal(forgeShapesOverlap(a, b), true); + assert.equal(forgeShapesOverlap(a, apart), false); +}); + +test('forgeShapesOverlap — une paire rectangle/cercle retombe sur le test rectangle-rectangle', () => { + const rect = { left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle' }; + const circle = { left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle' }; + assert.equal(forgeShapesOverlap(rect, circle), true); // les rectangles englobants se chevauchent +}); diff --git a/static/js/play/__tests__/personnage-controller.test.js b/static/js/play/__tests__/personnage-controller.test.js index fb12a137..e24ca042 100644 --- a/static/js/play/__tests__/personnage-controller.test.js +++ b/static/js/play/__tests__/personnage-controller.test.js @@ -16,7 +16,7 @@ Object.assign(global, actionsMod); const { forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim, - forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, + forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, forgeWouldCollide, } = require('../personnage-controller.js'); function fakeSceneEl() { @@ -236,3 +236,69 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b assert.equal(worldEl.style.transform, 'translate(-3340px, 0px)'); } finally { global.document = previousDoc; } }); + +// Blocage physique du déplacement en cas de collision — demandé par +// l'utilisateur : "elle doit empêcher d'avancer en cas de collision". +// forgeWouldCollide lit la position des AUTRES objets directement sur le +// DOM (jamais screenData.elements, qui ne bouge jamais après le premier +// chargement) — un test document mock dédié, avec plusieurs éléments. +function withScene(objectsById, screenData, fn) { + const previousDoc = global.document; + global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } }; + global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }]; + global.window.currentScreenId = 1; + try { fn(); } finally { global.document = previousDoc; } +} + +test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approche', () => { + const player = { id: 20, kind: 'personnage' }; + const wallEl = { style: { left: '140px', top: '100px', width: '20px', height: '20px' } }; + const screenData = { elements: [player, { id: 21, kind: 'decor' }] }; + withScene({ 21: wallEl }, screenData, () => { + // Le joueur (20x20) tente d'aller en (130,100) -> chevauche le mur (140..160, 100..120). + assert.equal(forgeWouldCollide(screenData, player, 130, 100, 20, 20), true); + // Assez loin : aucun chevauchement. + assert.equal(forgeWouldCollide(screenData, player, 0, 0, 20, 20), false); + }); +}); + +test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor à collision désactivée', () => { + const player = { id: 22, kind: 'personnage' }; + const bgEl = { style: { left: '0px', top: '0px', width: '3840px', height: '2160px' } }; + const disabledEl = { style: { left: '130px', top: '100px', width: '20px', height: '20px' } }; + const screenData = { + elements: [ + player, + { id: 23, kind: 'fond' }, + { id: 24, kind: 'decor', collision: { enabled: false } }, + ], + }; + withScene({ 23: bgEl, 24: disabledEl }, screenData, () => { + assert.equal(forgeWouldCollide(screenData, player, 130, 100, 20, 20), false); + }); +}); + +test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais dans le mur)', () => { + const player = { id: 25, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const wall = { id: 26, kind: 'decor' }; + const playerEl = fakeSceneEl(); // left/top 100px, 20x20 + const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; + withPersonnageObject(player, { 25: playerEl, 26: wallEl }, () => { + global.heldKeys = new Set(['d']); // vers la droite, vers le mur juste à côté + forgeRunPersonnageControllerTick({ elements: [player, wall] }); + assert.equal(playerEl.style.left, '100px'); // bloqué, jamais déplacé dans le mur + }); +}); + +test('tick — glisse le long d\'un mur en diagonale (un seul axe bloqué)', () => { + const player = { id: 27, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } }; + const wall = { id: 28, kind: 'decor' }; + const playerEl = fakeSceneEl(); // left/top 100px, 20x20 + const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; // bloque seulement à droite + withPersonnageObject(player, { 27: playerEl, 28: wallEl }, () => { + global.heldKeys = new Set(['d', 's']); // droite (bloquée) + bas (libre) + forgeRunPersonnageControllerTick({ elements: [player, wall] }); + assert.equal(playerEl.style.left, '100px'); // bloqué + assert.equal(playerEl.style.top, '104px'); // continue de descendre + }); +}); diff --git a/static/js/play/conditions.js b/static/js/play/conditions.js index bbeee776..c33ed97c 100644 --- a/static/js/play/conditions.js +++ b/static/js/play/conditions.js @@ -117,7 +117,8 @@ function forgeCollisionRectFromBox(box, collision) { const offsetY = (collision && collision.offset_y) || 0; const left = box.left + (box.width - width) / 2 + offsetX; const top = box.top + (box.height - height) / 2 + offsetY; - return { left, top, right: left + width, bottom: top + height, width, height }; + const shape = (collision && collision.shape) || 'rectangle'; + return { left, top, right: left + width, bottom: top + height, width, height, shape }; } function forgeRectanglesOverlap(a, b) { @@ -135,6 +136,17 @@ function forgeCirclesOverlap(a, b) { return Math.sqrt(dx * dx + dy * dy) < (ar + br); } +// Choisit rectangle-rectangle ou cercle-cercle selon la forme des DEUX +// boîtes (voir forgeCollisionRectFromBox, qui pose `shape` dessus) — +// réutilisée aussi bien par elementsOverlap() ci-dessous (condition de +// flow) que par forgeWouldCollide() (personnage-controller.js, blocage +// physique du déplacement au clavier) : UNE seule règle de "qu'est-ce qui +// se touche", jamais deux implémentations qui pourraient diverger. +function forgeShapesOverlap(a, b) { + if (a.shape === 'cercle' && b.shape === 'cercle') return forgeCirclesOverlap(a, b); + return forgeRectanglesOverlap(a, b); +} + // Phase 5 — condition de collision : chevauchement des BOÎTES DE // COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments, // positionnées sur leur rectangle RÉEL à l'écran (getBoundingClientRect — @@ -159,10 +171,7 @@ function elementsOverlap(elementIdA, elementIdB) { } const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision); const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision); - const shapeA = (objA && objA.collision && objA.collision.shape) || 'rectangle'; - const shapeB = (objB && objB.collision && objB.collision.shape) || 'rectangle'; - if (shapeA === 'cercle' && shapeB === 'cercle') return forgeCirclesOverlap(rectA, rectB); - return forgeRectanglesOverlap(rectA, rectB); + return forgeShapesOverlap(rectA, rectB); } function evaluateConditionClause(clause) { @@ -207,6 +216,6 @@ function evaluateConditionNode(node) { if (typeof module !== 'undefined' && module.exports) { module.exports = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode, - forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, + forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, forgeShapesOverlap, }; } diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 23808699..33e2746a 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -97,6 +97,51 @@ function forgeStopPersonnageControllers() { } } +// 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) { @@ -110,16 +155,37 @@ function forgeRunPersonnageControllerTick(screenData) { var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]'); if (!targetEl) return; - var dx = 0, dy = 0; + var rawDx = 0, rawDy = 0; if (cmd.axe !== 'vertical') { - if (heldKeys.has(cmd.gauche)) dx -= cmd.vitesse; - if (heldKeys.has(cmd.droite)) dx += cmd.vitesse; + 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)) dy -= cmd.vitesse; - if (heldKeys.has(cmd.bas)) dy += cmd.vitesse; + 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 @@ -129,10 +195,7 @@ function forgeRunPersonnageControllerTick(screenData) { 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 (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 })); @@ -195,5 +258,12 @@ if (typeof module !== 'undefined' && 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 + //