Dev #10
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// <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
|
||||
// objet de scène (pas seulement "personnage") — même patron AJAX que les
|
||||
// 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() {
|
||||
var body = document.getElementById('collisionPanelBody');
|
||||
if (!body) return;
|
||||
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', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
}),
|
||||
body: JSON.stringify({ enabled: enabled, shape: shape, width: width, height: height, offset_x: offsetX, offset_y: offsetY }),
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// déplacement/interaction + blocage d'axe + touches supplémentaires d'un
|
||||
// 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;
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
|
||||
.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 }})">
|
||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</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>
|
||||
{% endfor %}
|
||||
</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