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>
222 lines
12 KiB
JavaScript
222 lines
12 KiB
JavaScript
// ---------- Évaluation des conditions ----------
|
|
// Extrait de templates/play.html (voir plan de modularisation) : partie
|
|
// PURE du moteur (aucun accès DOM) — lecture de champs/variables et
|
|
// comparaison, c'est la logique la plus amenée à grossir (nouvelles
|
|
// opérations...), donc la plus utile à tester (voir
|
|
// static/js/play/__tests__/conditions.test.js). SEULE EXCEPTION :
|
|
// elementsOverlap() (Phase 5, condition de collision) touche forcément le
|
|
// DOM (getBoundingClientRect) — isolée dans sa propre fonction, non
|
|
// testée par node:test (voir son commentaire), le reste du fichier reste
|
|
// pur et testable.
|
|
|
|
// Lit la valeur actuelle d'un champ d'objet dans l'instantané de données
|
|
// du jeu (gameData.data), pour l'évaluation d'une condition.
|
|
function readFieldValue(definitionId, rowId, fieldName) {
|
|
// CLICKED_ROW_ID (-1, voir flow_node_run_data.py) : "la ligne de
|
|
// Répéteur sur laquelle on vient de cliqué" — jamais connue à l'avance
|
|
// dans l'éditeur (choisie ici via "🖱️ Ligne cliquée (Répéteur)"),
|
|
// résolue seulement au moment de l'évaluation via le dernier clic
|
|
// capturé (voir bindClicks() dans triggers.js).
|
|
if (rowId === -1) rowId = window.lastClickedRowId;
|
|
const rows = gameData.data[String(definitionId)] || [];
|
|
const row = rows.find(r => r.id === rowId);
|
|
return row ? row[fieldName] : undefined;
|
|
}
|
|
|
|
function compareValues(actual, operator, expected, fieldType) {
|
|
if (fieldType === 'booleen') {
|
|
const a = actual ? 1 : 0;
|
|
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
|
// partout ailleurs pour un champ booléen — une valeur de comparaison
|
|
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
|
|
// seulement "1"/"true" (et insensible à la casse, aligné avec
|
|
// _compare() côté Python, voir filter_repeater_rows.py).
|
|
const expectedStr = String(expected).trim().toLowerCase();
|
|
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
|
|
return operator === 'different' ? a !== e : a === e;
|
|
}
|
|
const an = parseFloat(actual), en = parseFloat(expected);
|
|
const numeric = !isNaN(an) && !isNaN(en);
|
|
if (numeric) {
|
|
switch (operator) {
|
|
case 'egal': return an === en;
|
|
case 'different': return an !== en;
|
|
case 'superieur': return an > en;
|
|
case 'inferieur': return an < en;
|
|
case 'superieur_egal': return an >= en;
|
|
case 'inferieur_egal': return an <= en;
|
|
}
|
|
}
|
|
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
|
const es = (expected === undefined || expected === null) ? '' : String(expected);
|
|
switch (operator) {
|
|
case 'egal': return as === es;
|
|
case 'different': return as !== es;
|
|
case 'superieur': return as > es;
|
|
case 'inferieur': return as < es;
|
|
case 'superieur_egal': return as >= es;
|
|
case 'inferieur_egal': return as <= es;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
// Lit la valeur ACTUELLE d'une variable globale (gameData.variables,
|
|
// exposé par full_game_payload.py, tenu à jour par refreshRuntimeData()
|
|
// après toute action qui en modifie une), pour l'évaluation d'une
|
|
// condition — équivalent, côté client, de _resolve_filter_value côté
|
|
// serveur (screens/rendering/filter_repeater_rows.py), mais SANS la
|
|
// syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais
|
|
// nécessaire ici : le nom de la variable est choisi dans un menu
|
|
// déroulant, voir screen_edit.html).
|
|
function readVariableValue(name) {
|
|
const v = (gameData.variables || {})[name];
|
|
return v ? v.value : undefined;
|
|
}
|
|
|
|
// Navigue dans une valeur JSON (variable de type "objet"/"tableau")
|
|
// selon un chemin ".champ"/"[index]" chaînable — équivalent JS de
|
|
// _resolve_variable_path (même fichier Python que ci-dessus). Chemin
|
|
// vide -> valeur brute inchangée (le cas normal pour une variable
|
|
// scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond
|
|
// à rien -> null, comme côté serveur.
|
|
function resolveVariablePath(rawValue, path) {
|
|
if (!path) return rawValue;
|
|
let current;
|
|
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
|
|
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
|
|
let m;
|
|
while ((m = segmentRe.exec(path)) !== null) {
|
|
if (current === null || current === undefined) return null;
|
|
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
|
}
|
|
return current === undefined ? null : current;
|
|
}
|
|
|
|
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
|
|
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
|
|
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
|
|
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
|
|
//
|
|
// Chaque clause peut tester soit un champ d'objet (source absente/"objet",
|
|
// comportement historique), soit une VARIABLE GLOBALE (source
|
|
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
|
|
// cond_variable_chemin).
|
|
// 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;
|
|
const shape = (collision && collision.shape) || 'rectangle';
|
|
return { left, top, right: left + width, bottom: top + height, width, height, shape };
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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 —
|
|
// 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 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);
|
|
return forgeShapesOverlap(rectA, rectB);
|
|
}
|
|
|
|
function evaluateConditionClause(clause) {
|
|
const source = clause.source ?? clause.cond_source ?? 'objet';
|
|
const operator = clause.operator ?? clause.cond_operator;
|
|
const expected = clause.value ?? clause.cond_value;
|
|
if (source === 'collision') {
|
|
// Pas d'opérateur/valeur à comparer (contrairement à "objet"/
|
|
// "variable") : le chevauchement EST directement le booléen vrai/faux
|
|
// du nœud — pour "ne se touchent pas", le créateur relie simplement
|
|
// le port "Faux" plutôt que "Vrai" dans le graphe, comme pour
|
|
// n'importe quelle autre condition.
|
|
return elementsOverlap(clause.element_a ?? clause.cond_element_a, clause.element_b ?? clause.cond_element_b);
|
|
}
|
|
if (source === 'variable') {
|
|
const varName = clause.variable ?? clause.cond_variable;
|
|
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
|
const varInfo = (gameData.variables || {})[varName];
|
|
const actual = resolveVariablePath(readVariableValue(varName), path);
|
|
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
|
return compareValues(actual, operator, expected, fieldType);
|
|
}
|
|
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
|
|
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
|
|
}
|
|
|
|
function evaluateConditionNode(node) {
|
|
if (node.cond_clauses) {
|
|
let clauses;
|
|
try { clauses = JSON.parse(node.cond_clauses); } catch (e) { clauses = null; }
|
|
if (Array.isArray(clauses) && clauses.length) {
|
|
const results = clauses.map(evaluateConditionClause);
|
|
return node.cond_combinator === 'ou' ? results.some(Boolean) : results.every(Boolean);
|
|
}
|
|
}
|
|
return evaluateConditionClause(node);
|
|
}
|
|
|
|
// static/js/play/__tests__/ (node:test) importe ces fonctions via
|
|
// 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,
|
|
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, forgeShapesOverlap,
|
|
};
|
|
}
|