Aperçu de la boîte de collision dans l'éditeur + blocage physique au jeu
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bc3feb5080
commit
a231bcf2fa
@@ -16,6 +16,7 @@ const {
|
|||||||
forgeCollisionRectFromBox,
|
forgeCollisionRectFromBox,
|
||||||
forgeRectanglesOverlap,
|
forgeRectanglesOverlap,
|
||||||
forgeCirclesOverlap,
|
forgeCirclesOverlap,
|
||||||
|
forgeShapesOverlap,
|
||||||
} = require('../conditions.js');
|
} = require('../conditions.js');
|
||||||
|
|
||||||
function withGameData(data, fn) {
|
function withGameData(data, fn) {
|
||||||
@@ -127,19 +128,19 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
|
|||||||
// forme, activée/désactivée).
|
// forme, activée/désactivée).
|
||||||
test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => {
|
test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => {
|
||||||
const box = { left: 10, top: 20, width: 40, height: 60 };
|
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)', () => {
|
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 box = { left: 0, top: 0, width: 100, height: 100 };
|
||||||
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 });
|
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', () => {
|
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 box = { left: 0, top: 0, width: 100, height: 100 };
|
||||||
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 });
|
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', () => {
|
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, touching), true);
|
||||||
assert.equal(forgeCirclesOverlap(a, apart), false);
|
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
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Object.assign(global, actionsMod);
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
||||||
forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, forgeWouldCollide,
|
||||||
} = require('../personnage-controller.js');
|
} = require('../personnage-controller.js');
|
||||||
|
|
||||||
function fakeSceneEl() {
|
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)');
|
assert.equal(worldEl.style.transform, 'translate(-3340px, 0px)');
|
||||||
} finally { global.document = previousDoc; }
|
} 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ function forgeCollisionRectFromBox(box, collision) {
|
|||||||
const offsetY = (collision && collision.offset_y) || 0;
|
const offsetY = (collision && collision.offset_y) || 0;
|
||||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
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) {
|
function forgeRectanglesOverlap(a, b) {
|
||||||
@@ -135,6 +136,17 @@ function forgeCirclesOverlap(a, b) {
|
|||||||
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
|
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
|
// Phase 5 — condition de collision : chevauchement des BOÎTES DE
|
||||||
// COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments,
|
// COLLISION (voir forgeCollisionRectFromBox ci-dessus) de deux éléments,
|
||||||
// positionnées sur leur rectangle RÉEL à l'écran (getBoundingClientRect —
|
// 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 rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
||||||
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
|
const rectB = forgeCollisionRectFromBox(b.getBoundingClientRect(), objB && objB.collision);
|
||||||
const shapeA = (objA && objA.collision && objA.collision.shape) || 'rectangle';
|
return forgeShapesOverlap(rectA, rectB);
|
||||||
const shapeB = (objB && objB.collision && objB.collision.shape) || 'rectangle';
|
|
||||||
if (shapeA === 'cercle' && shapeB === 'cercle') return forgeCirclesOverlap(rectA, rectB);
|
|
||||||
return forgeRectanglesOverlap(rectA, rectB);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateConditionClause(clause) {
|
function evaluateConditionClause(clause) {
|
||||||
@@ -207,6 +216,6 @@ function evaluateConditionNode(node) {
|
|||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode,
|
readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode,
|
||||||
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap,
|
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, forgeShapesOverlap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||||
// (triggers.js), pour un déplacement perçu comme continu.
|
// (triggers.js), pour un déplacement perçu comme continu.
|
||||||
function forgeRunPersonnageControllerTick(screenData) {
|
function forgeRunPersonnageControllerTick(screenData) {
|
||||||
@@ -110,16 +155,37 @@ function forgeRunPersonnageControllerTick(screenData) {
|
|||||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
|
|
||||||
var dx = 0, dy = 0;
|
var rawDx = 0, rawDy = 0;
|
||||||
if (cmd.axe !== 'vertical') {
|
if (cmd.axe !== 'vertical') {
|
||||||
if (heldKeys.has(cmd.gauche)) dx -= cmd.vitesse;
|
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||||
if (heldKeys.has(cmd.droite)) dx += cmd.vitesse;
|
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||||
}
|
}
|
||||||
if (cmd.axe !== 'horizontal') {
|
if (cmd.axe !== 'horizontal') {
|
||||||
if (heldKeys.has(cmd.haut)) dy -= cmd.vitesse;
|
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
|
||||||
if (heldKeys.has(cmd.bas)) dy += 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
|
// 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
|
// 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
|
// 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 }));
|
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dx !== 0) {
|
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||||
applyObjectProperty(targetEl, 'orientation', dx < 0 ? 'gauche' : 'droite');
|
|
||||||
applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
|
||||||
}
|
|
||||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||||
var moveAnim = forgePersonnageMovementAnim(obj.id);
|
var moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||||
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
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,
|
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
||||||
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
||||||
forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,23 +163,112 @@ function saveSceneObjectRole(role) {
|
|||||||
// static/js/play/conditions.js::elementsOverlap) : disponible pour TOUT
|
// static/js/play/conditions.js::elementsOverlap) : disponible pour TOUT
|
||||||
// objet de scène (pas seulement "personnage") — même patron AJAX que les
|
// objet de scène (pas seulement "personnage") — même patron AJAX que les
|
||||||
// fonctions ci-dessus, sur routes/scenes/scene_object_collision.py.
|
// fonctions ci-dessus, sur routes/scenes/scene_object_collision.py.
|
||||||
|
// Recale aussi l'aperçu (#collisionBoxOverlay, seulement présent pour
|
||||||
|
// l'objet sélectionné) pour rester en phase avec les champs, qu'ils
|
||||||
|
// viennent d'être tapés à la main ou ajustés au glisser (voir
|
||||||
|
// onCollisionBoxMouseDown/onCollisionBoxResizeMouseDown plus bas).
|
||||||
function saveSceneObjectCollision() {
|
function saveSceneObjectCollision() {
|
||||||
var body = document.getElementById('collisionPanelBody');
|
var body = document.getElementById('collisionPanelBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
var objectId = parseInt(body.dataset.elementId, 10);
|
var objectId = parseInt(body.dataset.elementId, 10);
|
||||||
|
var enabled = document.getElementById('collision-enabled').checked;
|
||||||
|
var shape = document.getElementById('collision-shape').value;
|
||||||
|
var width = parseFloat(document.getElementById('collision-width').value) || 0;
|
||||||
|
var height = parseFloat(document.getElementById('collision-height').value) || 0;
|
||||||
|
var offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||||
|
var offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||||
|
|
||||||
|
var overlay = document.getElementById('collisionBoxOverlay');
|
||||||
|
var objEl = document.getElementById('el-' + objectId);
|
||||||
|
if (overlay && objEl) {
|
||||||
|
var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0;
|
||||||
|
overlay.style.width = width + 'px';
|
||||||
|
overlay.style.height = height + 'px';
|
||||||
|
overlay.style.left = ((objWidth - width) / 2 + offsetX) + 'px';
|
||||||
|
overlay.style.top = ((objHeight - height) / 2 + offsetY) + 'px';
|
||||||
|
overlay.classList.toggle('is-circle', shape === 'cercle');
|
||||||
|
overlay.classList.toggle('is-disabled', !enabled);
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision', {
|
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ enabled: enabled, shape: shape, width: width, height: height, offset_x: offsetX, offset_y: offsetY }),
|
||||||
enabled: document.getElementById('collision-enabled').checked,
|
|
||||||
shape: document.getElementById('collision-shape').value,
|
|
||||||
width: document.getElementById('collision-width').value,
|
|
||||||
height: document.getElementById('collision-height').value,
|
|
||||||
offset_x: document.getElementById('collision-offset-x').value,
|
|
||||||
offset_y: document.getElementById('collision-offset-y').value,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCollisionFields(width, height, offsetX, offsetY) {
|
||||||
|
var wEl = document.getElementById('collision-width'), hEl = document.getElementById('collision-height');
|
||||||
|
var oxEl = document.getElementById('collision-offset-x'), oyEl = document.getElementById('collision-offset-y');
|
||||||
|
if (wEl) wEl.value = Math.round(width);
|
||||||
|
if (hEl) hEl.value = Math.round(height);
|
||||||
|
if (oxEl) oxEl.value = Math.round(offsetX);
|
||||||
|
if (oyEl) oyEl.value = Math.round(offsetY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glisser le CORPS de l'aperçu ajuste son décalage (offset_x/offset_y) —
|
||||||
|
// mirror d'onSceneObjectMouseDown ci-dessus, mais sur la boîte de
|
||||||
|
// collision plutôt que l'objet lui-même (jamais le même geste : cliquer
|
||||||
|
// l'objet EN DEHORS de la boîte de collision continue de le déplacer
|
||||||
|
// normalement, la boîte est un enfant plus petit posé par-dessus).
|
||||||
|
function onCollisionBoxMouseDown(e, id) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.target.classList.contains('collisionResizeHandle')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var overlay = document.getElementById('collisionBoxOverlay');
|
||||||
|
var objEl = document.getElementById('el-' + id);
|
||||||
|
var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0;
|
||||||
|
var startX = e.clientX, startY = e.clientY;
|
||||||
|
var startLeft = parseFloat(overlay.style.left) || 0, startTop = parseFloat(overlay.style.top) || 0;
|
||||||
|
var boxWidth = parseFloat(overlay.style.width) || 0, boxHeight = parseFloat(overlay.style.height) || 0;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
overlay.style.left = (startLeft + (ev.clientX - startX)) + 'px';
|
||||||
|
overlay.style.top = (startTop + (ev.clientY - startY)) + 'px';
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
var offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||||
|
var offsetY = (parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||||
|
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
||||||
|
saveSceneObjectCollision();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glisser la poignée (coin bas-droit) redimensionne la boîte de collision
|
||||||
|
// — mirror d'onSceneObjectResizeMouseDown, ancré sur son coin haut-gauche
|
||||||
|
// (le centre se déplace donc légèrement à chaque redimensionnement, sauf
|
||||||
|
// à recalculer l'offset pour le garder fixe : on choisit de garder le
|
||||||
|
// COIN fixe, plus prévisible au glisser qu'un centre qui "fuit").
|
||||||
|
function onCollisionBoxResizeMouseDown(e, id) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
var overlay = document.getElementById('collisionBoxOverlay');
|
||||||
|
var objEl = document.getElementById('el-' + id);
|
||||||
|
var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0;
|
||||||
|
var startX = e.clientX, startY = e.clientY;
|
||||||
|
var startWidth = parseFloat(overlay.style.width) || 0, startHeight = parseFloat(overlay.style.height) || 0;
|
||||||
|
var left = parseFloat(overlay.style.left) || 0, top = parseFloat(overlay.style.top) || 0;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
overlay.style.width = Math.max(4, startWidth + (ev.clientX - startX)) + 'px';
|
||||||
|
overlay.style.height = Math.max(4, startHeight + (ev.clientY - startY)) + 'px';
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
var newWidth = parseFloat(overlay.style.width), newHeight = parseFloat(overlay.style.height);
|
||||||
|
var offsetX = left - (objWidth - newWidth) / 2;
|
||||||
|
var offsetY = top - (objHeight - newHeight) / 2;
|
||||||
|
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
||||||
|
saveSceneObjectCollision();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
// "Commandes" (voir static/js/play/personnage-controller.js) : touches de
|
// "Commandes" (voir static/js/play/personnage-controller.js) : touches de
|
||||||
// déplacement/interaction + blocage d'axe + touches supplémentaires d'un
|
// déplacement/interaction + blocage d'axe + touches supplémentaires d'un
|
||||||
// personnage — un seul formulaire, enregistré en AJAX à chaque
|
// personnage — un seul formulaire, enregistré en AJAX à chaque
|
||||||
|
|||||||
@@ -654,6 +654,21 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
|||||||
background:var(--accent); border-radius:3px 0 6px 0; opacity:.85;
|
background:var(--accent); border-radius:3px 0 6px 0; opacity:.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Aperçu de la boîte de collision dans l'éditeur (voir "🧱 Collision",
|
||||||
|
templates/scene_edit.html) — seulement pour l'objet SÉLECTIONNÉ.
|
||||||
|
pointer-events déjà actifs par défaut (contrairement à .sceneBackground
|
||||||
|
au jeu) : c'est justement le but, la faire glisser/redimensionner. */
|
||||||
|
.collisionBoxOverlay{
|
||||||
|
position:absolute; box-sizing:border-box; border:2px dashed #ff5c5c;
|
||||||
|
background:rgba(255,92,92,.12); cursor:move; z-index:20;
|
||||||
|
}
|
||||||
|
.collisionBoxOverlay.is-circle{ border-radius:50%; }
|
||||||
|
.collisionBoxOverlay.is-disabled{ border-color:#8b95a7; background:rgba(139,149,167,.1); }
|
||||||
|
.collisionResizeHandle{
|
||||||
|
position:absolute; right:-6px; bottom:-6px; width:12px; height:12px; cursor:nwse-resize;
|
||||||
|
background:#ff5c5c; border-radius:50%; border:2px solid #1a1d24;
|
||||||
|
}
|
||||||
|
|
||||||
.listRow.selected{ border-color:var(--accent); background:var(--panel2); }
|
.listRow.selected{ border-color:var(--accent); background:var(--panel2); }
|
||||||
|
|
||||||
.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); }
|
.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); }
|
||||||
|
|||||||
@@ -107,6 +107,18 @@
|
|||||||
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
||||||
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
|
{% if o.id == selected_id %}
|
||||||
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
||||||
|
aperçu visuel + réglable directement au glisser — seulement pour
|
||||||
|
l'objet SÉLECTIONNÉ (celui dont le panneau "🧱 Collision" est ouvert),
|
||||||
|
voir initCollisionBoxOverlay() dans scene-editor.js. -->
|
||||||
|
<div class="collisionBoxOverlay {{ 'is-circle' if collision_settings.shape == 'cercle' else '' }} {{ 'is-disabled' if not collision_settings.enabled else '' }}"
|
||||||
|
id="collisionBoxOverlay"
|
||||||
|
style="left:{{ (o.width - collision_settings.width) / 2 + collision_settings.offset_x }}px; top:{{ (o.height - collision_settings.height) / 2 + collision_settings.offset_y }}px; width:{{ collision_settings.width }}px; height:{{ collision_settings.height }}px;"
|
||||||
|
onmousedown="onCollisionBoxMouseDown(event, {{ o.id }})">
|
||||||
|
<div class="collisionResizeHandle" onmousedown="onCollisionBoxResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Aperçu visuel de la boîte de collision dans l'éditeur de scène (voir
|
||||||
|
"🧱 Collision", templates/scene_edit.html) — demandé par l'utilisateur :
|
||||||
|
"quand je suis dans l'éditeur je veux voir la boîte de collision et régler
|
||||||
|
sa taille". Rendu SEULEMENT pour l'objet sélectionné (voir
|
||||||
|
static/js/scenes/scene-editor.js::onCollisionBoxMouseDown/
|
||||||
|
onCollisionBoxResizeMouseDown pour le glisser-déplacer/redimensionner)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
|
||||||
|
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_overlay"):
|
||||||
|
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||||
|
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||||
|
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||||
|
return slug, screen_id
|
||||||
|
|
||||||
|
|
||||||
|
def _add_decor(client, slug, screen_id):
|
||||||
|
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 302, resp.data
|
||||||
|
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_object_renders_the_collision_overlay_with_drag_handlers(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = _add_decor(client, slug, screen_id)
|
||||||
|
|
||||||
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
|
||||||
|
assert 'id="collisionBoxOverlay"' in html
|
||||||
|
assert f"onCollisionBoxMouseDown(event, {obj_id})" in html
|
||||||
|
assert f"onCollisionBoxResizeMouseDown(event, {obj_id})" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_selected_object_gets_an_overlay(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = _add_decor(client, slug, screen_id)
|
||||||
|
_add_decor(client, slug, screen_id) # un second objet, jamais sélectionné ici
|
||||||
|
|
||||||
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
|
||||||
|
assert html.count('id="collisionBoxOverlay"') == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_reflects_a_custom_collision_box_size_and_offset(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = _add_decor(client, slug, screen_id)
|
||||||
|
client.post(
|
||||||
|
f"/game/{slug}/scene-objects/{obj_id}/collision",
|
||||||
|
data='{"enabled": true, "shape": "cercle", "width": 40, "height": 40, "offset_x": 10, "offset_y": -10}',
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
|
||||||
|
assert "is-circle" in html
|
||||||
|
assert "width:40.0px; height:40.0px" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_collision_marks_the_overlay(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = _add_decor(client, slug, screen_id)
|
||||||
|
client.post(f"/game/{slug}/scene-objects/{obj_id}/collision", data='{"enabled": false}', content_type="application/json")
|
||||||
|
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
|
||||||
|
assert "is-disabled" in html
|
||||||
Reference in New Issue
Block a user