Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
237 lines
12 KiB
JavaScript
237 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') {
|
|
// Bug corrigé : une variable booléenne est stockée en TEXTE
|
|
// ("vrai"/"faux", voir gameData.variables) — `actual ? 1 : 0` prenait
|
|
// n'importe quelle chaîne non vide pour vraie, y compris la chaîne
|
|
// "faux" elle-même ! Les deux côtés doivent passer par la MÊME
|
|
// normalisation texte->booléen, pas seulement le côté "expected".
|
|
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
|
// partout ailleurs pour un champ booléen — une valeur tapée "Oui"
|
|
// doit donc être reconnue vraie ici aussi, pas seulement "1"/"true"
|
|
// (et insensible à la casse, aligné avec _compare() côté Python).
|
|
const normalizeBool = function (v) {
|
|
if (v === true) return 1;
|
|
if (v === false || v === null || v === undefined) return 0;
|
|
return ['1', 'true', 'vrai', 'oui'].includes(String(v).trim().toLowerCase()) ? 1 : 0;
|
|
};
|
|
const a = normalizeBool(actual);
|
|
const e = normalizeBool(expected);
|
|
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);
|
|
// "egal"/"different" insensibles à la casse (bug corrigé : une valeur de
|
|
// comparaison texte tapée avec une casse différente de la valeur réelle
|
|
// — ex. "Vrai" vs "vrai" — était jugée différente à tort, alors que le
|
|
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
|
|
// superieur/inferieur restent une comparaison lexicographique brute
|
|
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
|
|
const asLower = as.toLowerCase(), esLower = es.toLowerCase();
|
|
switch (operator) {
|
|
case 'egal': return asLower === esLower;
|
|
case 'different': return asLower !== esLower;
|
|
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,
|
|
};
|
|
}
|