Panneau scène simplifié + boîte de collision + rôle joueur/ennemi/pnj
Build and deploy / test-python (push) Successful in 11m52s
Build and deploy / test-js (push) Successful in 56s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Trois retours utilisateur sur l'éditeur de scène 2D :

- Retire l'arborescence "Objets de cette scène" (un objet reste
  sélectionnable en cliquant dessus sur le canevas) et le bouton
  "+ Ajouter un décor" (le type "decor" reste supporté côté serveur,
  juste plus accessible depuis ce menu).

- Chaque objet de scène a désormais une boîte de collision AUTOMATIQUE
  (= sa propre boîte, comportement inchangé pour une condition de
  collision déjà posée) réglable dans un nouveau panneau "🧱 Collision" :
  activée/désactivée, forme (rectangle/cercle), taille et décalage —
  utile pour un sprite très paddé (CraftPix) dont la silhouette réelle
  est bien plus petite que son canevas. elementsOverlap() (conditions.js)
  applique ces réglages en restant identique par défaut.

- Nouveau champ "🏷️ Rôle" (Joueur/Ennemi/PNJ) sur un personnage : SEUL un
  personnage "Joueur" est désormais déplacé/animé au clavier et suivi
  par la caméra (personnage-controller.js) — "Ennemi"/"PNJ" restent
  immobiles tant qu'aucune logique de flow ne les pilote (déplacement
  ennemi automatique/apparition, dialogue de PNJ... hors scope de ce
  réglage, qui ne fait qu'identifier "quel objet est le joueur").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 15:30:34 +02:00
co-authored by Claude Sonnet 5
parent 9199897784
commit bc3feb5080
19 changed files with 602 additions and 49 deletions
@@ -13,6 +13,9 @@ const {
readVariableValue,
evaluateConditionClause,
evaluateConditionNode,
forgeCollisionRectFromBox,
forgeRectanglesOverlap,
forgeCirclesOverlap,
} = require('../conditions.js');
function withGameData(data, fn) {
@@ -117,3 +120,37 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
assert.equal(evaluateConditionNode(node), true);
});
});
// Boîte de collision (voir screens/rendering/collision_settings.py) —
// demandé par l'utilisateur : chaque objet a automatiquement une boîte de
// collision (= sa boîte visuelle par défaut), configurable (taille,
// 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 });
});
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 });
});
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 });
});
test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => {
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 5, top: 5, right: 15, bottom: 15 }), true);
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 20, top: 20, right: 30, bottom: 30 }), false);
});
test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => {
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20 }; // centre (10,10), rayon 10
const touching = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20 }; // centre (25,10), rayon 10, distance 15 < 20
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20 };
assert.equal(forgeCirclesOverlap(a, touching), true);
assert.equal(forgeCirclesOverlap(a, apart), false);
});
@@ -53,7 +53,7 @@ test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux s
});
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
const obj = { id: 10, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = { id: 10, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 10: el }, () => {
global.heldKeys = new Set(['d']);
@@ -64,7 +64,7 @@ test('tick — touche "droite" enfoncée : déplace vers la droite et retire le
});
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
const obj = { id: 11, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = { id: 11, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 11: el }, () => {
global.heldKeys = new Set(['q']);
@@ -75,7 +75,7 @@ test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique l
});
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
const obj = { id: 12, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
const obj = { id: 12, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 12: el }, () => {
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
@@ -85,7 +85,7 @@ test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfonc
});
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
const obj = { id: 13, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
const obj = { id: 13, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 13: el }, () => {
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
@@ -95,7 +95,7 @@ test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfo
});
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
const obj = { id: 14, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
const obj = { id: 14, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
const el = fakeSceneEl();
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
withPersonnageObject(obj, { 14: el }, () => {
@@ -106,7 +106,7 @@ test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () =
});
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
const obj = { id: 15, kind: 'personnage', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const obj = { id: 15, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(obj, { 15: el }, () => {
global.heldKeys = new Set();
@@ -126,6 +126,20 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman
});
});
// Rôle (voir screens/rendering/personnage_role.py) — demandé par
// l'utilisateur : seul un personnage "joueur" doit être déplacé/animé au
// clavier, "ennemie"/"pnj" doivent rester immobiles tant qu'aucune
// logique de flow ne les pilote.
test('tick — un personnage "ennemie"/"pnj" n\'est jamais déplacé au clavier', () => {
const ennemi = { id: 17, kind: 'personnage', personnage_role: 'ennemie', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
const el = fakeSceneEl();
withPersonnageObject(ennemi, { 17: el }, () => {
global.heldKeys = new Set(['d']);
forgeRunPersonnageControllerTick({ elements: [ennemi] });
assert.equal(el.style.left, '100px'); // inchangé, malgré la touche "droite" enfoncée
});
});
// Touches supplémentaires (sauter, attaquer...) — bug remonté par
// l'utilisateur : au-delà des 4 touches de déplacement et de "interagir",
// il manquait un moyen de lier une touche à N'IMPORTE QUELLE autre pose du
@@ -188,7 +202,7 @@ test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux b
id: 1, scene_width: 500, scene_height: 500,
elements: [
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
{ id: 42, kind: 'personnage' },
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
],
};
try {
@@ -212,7 +226,7 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b
id: 1, scene_width: 500, scene_height: 500,
elements: [
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
{ id: 42, kind: 'personnage' },
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
],
};
try {
+62 -10
View File
@@ -101,19 +101,68 @@ function resolveVariablePath(rawValue, path) {
// comportement historique), soit une VARIABLE GLOBALE (source
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
// cond_variable_chemin).
// Phase 5 — condition de collision : chevauchement des rectangles de
// deux éléments (getBoundingClientRect, donc leur position RÉELLE à
// l'écran — reflète les déplacements posés via "Modifier un élément →
// Position", voir static/js/play/actions.js). Aucun élément trouvé (id
// invalide, élément masqué avec display:none — getBoundingClientRect
// renvoie alors une boîte de largeur/hauteur nulles) => pas de
// chevauchement plutôt que de planter.
// Boîte de collision (voir screens/rendering/collision_settings.py) :
// centrée par défaut sur le rectangle VISUEL (getBoundingClientRect),
// réduite/décalée si réglée dans le panneau de propriétés — un sprite
// très paddé (ex. les frames CraftPix) peut ainsi avoir une silhouette de
// collision bien plus petite que son canevas. `collision` absent (élément
// de l'éditeur document, ou objet de scène posé avant l'ajout de ce
// réglage) => le rectangle visuel EXACT, comportement inchangé. Fonction
// PURE (aucun accès DOM) — testée par static/js/play/__tests__/
// conditions.test.js, contrairement à elementsOverlap() ci-dessous.
function forgeCollisionRectFromBox(box, collision) {
const width = (collision && collision.width) || box.width;
const height = (collision && collision.height) || box.height;
const offsetX = (collision && collision.offset_x) || 0;
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 };
}
function forgeRectanglesOverlap(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
// Cercle-cercle uniquement (rayon = le plus petit des deux côtés/2) — une
// paire rectangle/cercle retombe sur un chevauchement de rectangles (repli
// volontairement approximatif plutôt qu'une vraie intersection cercle-
// rectangle, hors scope de ce premier réglage).
function forgeCirclesOverlap(a, b) {
const acx = a.left + a.width / 2, acy = a.top + a.height / 2, ar = Math.min(a.width, a.height) / 2;
const bcx = b.left + b.width / 2, bcy = b.top + b.height / 2, br = Math.min(b.width, b.height) / 2;
const dx = acx - bcx, dy = acy - bcy;
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
}
// 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 —
// reflète les déplacements posés via "Modifier un élément"/"Modifier un
// objet de scène", voir static/js/play/actions.js, ET la caméra qui suit
// le personnage, voir personnage-controller.js : un décalage de caméra
// est un simple transform CSS, déjà reflété par getBoundingClientRect).
// Aucun élément trouvé (id invalide, élément masqué avec display:none —
// getBoundingClientRect renvoie alors une boîte de largeur/hauteur
// nulles), ou collision explicitement désactivée => pas de chevauchement
// plutôt que de planter.
function elementsOverlap(elementIdA, elementIdB) {
const a = document.querySelector('[data-element-id="' + elementIdA + '"]');
const b = document.querySelector('[data-element-id="' + elementIdB + '"]');
if (!a || !b) return false;
const r1 = a.getBoundingClientRect(), r2 = b.getBoundingClientRect();
return r1.left < r2.right && r1.right > r2.left && r1.top < r2.bottom && r1.bottom > r2.top;
const screenData = (typeof screensData !== 'undefined' && typeof window !== 'undefined')
&& screensData.find(function (s) { return s.id === window.currentScreenId; });
const objA = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdA; });
const objB = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdB; });
if ((objA && objA.collision && objA.collision.enabled === false) || (objB && objB.collision && objB.collision.enabled === false)) {
return false;
}
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);
}
function evaluateConditionClause(clause) {
@@ -156,5 +205,8 @@ function evaluateConditionNode(node) {
// require() — 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 = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode };
module.exports = {
readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode,
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap,
};
}
+7 -4
View File
@@ -77,7 +77,7 @@ function forgeUpdateSceneCamera(screenData) {
worldEl.style.transform = '';
return;
}
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage'; });
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;
@@ -102,7 +102,10 @@ function forgeStopPersonnageControllers() {
function forgeRunPersonnageControllerTick(screenData) {
forgeUpdateSceneCamera(screenData);
(screenData.elements || []).forEach(function (obj) {
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
// 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;
@@ -153,7 +156,7 @@ function forgePersonnageActionAnim(cmd, key) {
function forgeStartPersonnageInteractListener(screenData) {
activePersonnageInteractListener = function (ev) {
(screenData.elements || []).forEach(function (obj) {
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
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 + '"]');
@@ -171,7 +174,7 @@ 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'; });
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
+35
View File
@@ -145,6 +145,41 @@ function swapSceneObjectCharacter(forgeCharacter) {
});
}
// Rôle (voir screens/rendering/personnage_role.py) : seul un personnage
// "Joueur" est déplacé/animé automatiquement au clavier — même patron AJAX
// que swapSceneObjectCharacter() ci-dessus (POST JSON, aucun rechargement
// de page), sur routes/scenes/scene_object_role.py.
function saveSceneObjectRole(role) {
var body = document.getElementById('personnagePanelBody');
if (!body) return;
var objectId = parseInt(body.dataset.elementId, 10);
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }),
});
}
// Boîte de collision (voir screens/rendering/collision_settings.py,
// 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.
function saveSceneObjectCollision() {
var body = document.getElementById('collisionPanelBody');
if (!body) return;
var objectId = parseInt(body.dataset.elementId, 10);
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,
}),
});
}
// "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
+11 -6
View File
@@ -613,12 +613,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.sceneObjectSprite{ object-fit:contain; }
/* Objet de scène kind="fond" (image de fond, voir add_scene_object.py) :
insensible au clic (peut couvrir tout le "monde", ne doit jamais
intercepter un clic destiné à un objet posé par-dessus) et étirée pour
remplir exactement sa boîte (jamais "contain"/lettrboxée comme
.sceneObjectSprite ci-dessus : un fond redimensionné doit combler tout
l'espace, pas laisser des bandes vides). */
.sceneBackground{ pointer-events:none; object-fit:fill; }
étirée pour remplir exactement sa boîte (jamais "contain"/lettrboxée
comme .sceneObjectSprite ci-dessus : un fond redimensionné doit combler
tout l'espace, pas laisser des bandes vides). Insensible au clic
SEULEMENT au jeu (.playScreen .sceneBackground, peut couvrir tout le
"monde", ne doit jamais intercepter un clic destiné à un objet posé
par-dessus) — JAMAIS dans l'éditeur (.canvasElement, scene_edit.html) :
sans arborescence "Objets de cette scène" pour le sélectionner
autrement, un fond y resterait impossible à re-sélectionner/déplacer
au clic si on le rendait, lui aussi, insensible au clic. */
.sceneBackground{ object-fit:fill; }
.playScreen .sceneBackground{ pointer-events:none; }
/* Caméra qui suit le personnage (voir static/js/play/personnage-
controller.js) : .playScreen.playScene est désormais le VIEWPORT (taille