Phase 5 : position/déplacement d'élément + condition de collision
Ajoute le positionnement absolu (pos_x/pos_y, réutilise left/top en % déjà en place) et relatif (pos_x_relatif/pos_y_relatif, ajoute un delta à la position actuelle plutôt que de l'écraser) comme nouvelles propriétés de l'action "Modifier un élément". Ajoute une nouvelle source de condition "collision" (aux côtés de "objet"/"variable") : deux éléments (cond_element_a/cond_element_b, ALTER TABLE sans contrainte FK, même patron que block_id/ trigger_custom_event_id) dont on compare les rectangles à l'écran via getBoundingClientRect() côté client (elementsOverlap(), dans conditions.js). Pas d'opérateur/valeur à choisir : le chevauchement EST directement le booléen vrai/faux du nœud — le créateur relie le port "Faux" pour "ne se touchent pas", exactement comme pour n'importe quelle autre condition (design plus simple que réinterpréter égal/différent, qui n'a pas de sens pour superieur/inferieur). delete_element.py et flow_nodes_referencing_element.py nettoient désormais aussi les nœuds de collision référençant un élément supprimé (ou l'un de ses descendants), pour rester cohérents avec le nettoyage déjà en place pour trigger_element_id/target_element_id. Combiné à la Phase 3 (minuteur récurrent) et au déplacement au clavier, ça couvre des jeux type casse-briques/Pong/ramasse-objets sans construire un vrai moteur physique (pas de vélocité/accélération/ gravité continues, cadrage volontairement limité). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
92fbfbc9dd
commit
9e263435e6
@@ -14,20 +14,29 @@ def delete_element(slug, element_id):
|
|||||||
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||||
placeholders = ",".join("?" * len(ids))
|
placeholders = ",".join("?" * len(ids))
|
||||||
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
|
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
|
||||||
# élément"/action "Modifier cet élément"...) ou une ancienne action du
|
# élément"/action "Modifier cet élément"/condition de collision — voir
|
||||||
# système _actions (conservé pour compatibilité) qui référence
|
# ci-dessous...) ou une ancienne action du système _actions (conservé
|
||||||
# l'élément supprimé OU L'UN DE SES DESCENDANTS n'a plus aucun sens une
|
# pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES
|
||||||
# fois l'élément disparu — et ces colonnes (trigger_element_id/
|
# DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces
|
||||||
# target_element_id) n'ont volontairement PAS de ON DELETE CASCADE (un
|
# colonnes (trigger_element_id/target_element_id) n'ont volontairement
|
||||||
# élément ne doit pas pouvoir être supprimé "par erreur" en cascade
|
# PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être
|
||||||
# depuis un nœud de logique qu'on modifie). Sans ce nettoyage préalable,
|
# supprimé "par erreur" en cascade depuis un nœud de logique qu'on
|
||||||
# PRAGMA foreign_keys=ON (voir db/connection.py) fait échouer la
|
# modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir
|
||||||
# suppression elle-même avec "FOREIGN KEY constraint failed". Voir
|
# db/connection.py) fait échouer la suppression elle-même avec
|
||||||
# element_delete_impact.py pour prévenir l'utilisateur AVANT qu'il
|
# "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour
|
||||||
# confirme, plutôt que de supprimer ces nœuds en silence.
|
# prévenir l'utilisateur AVANT qu'il confirme, plutôt que de supprimer
|
||||||
|
# ces nœuds en silence.
|
||||||
|
#
|
||||||
|
# cond_element_a/cond_element_b (Phase 5, condition de collision) : ni
|
||||||
|
# contrainte FK ni ON DELETE CASCADE (voir ensure_flow_schema.py), mais
|
||||||
|
# un nœud qui compare le rectangle de CET élément à un autre n'a pas
|
||||||
|
# plus de sens que si c'était trigger_element_id/target_element_id —
|
||||||
|
# supprimé ici pour la même raison, pas parce que la base l'exigerait.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
|
f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
|
||||||
ids + ids,
|
OR target_element_id IN ({placeholders})
|
||||||
|
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
|
||||||
|
ids + ids + ids + ids,
|
||||||
)
|
)
|
||||||
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
|
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
|
||||||
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
|
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ from .element_descendant_ids import element_descendant_ids
|
|||||||
|
|
||||||
def flow_nodes_referencing_element(slug, element_id):
|
def flow_nodes_referencing_element(slug, element_id):
|
||||||
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
|
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
|
||||||
élément", action "Modifier cet élément"/"Activer cet onglet"...) qui
|
élément", action "Modifier cet élément"/"Activer cet onglet", condition
|
||||||
référencent cet élément OU L'UN DE SES DESCENDANTS — donc qui seraient
|
de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES
|
||||||
supprimés SI on supprimait cet élément (voir delete_element.py). Utilisé
|
DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément
|
||||||
pour prévenir l'utilisateur AVANT qu'il confirme la suppression (voir
|
(voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT
|
||||||
la modale de confirmation dans screen_edit.html), plutôt que de les
|
qu'il confirme la suppression (voir la modale de confirmation dans
|
||||||
supprimer en silence."""
|
screen_edit.html), plutôt que de les supprimer en silence."""
|
||||||
ensure_flow_schema(slug)
|
ensure_flow_schema(slug)
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||||
@@ -21,8 +21,10 @@ def flow_nodes_referencing_element(slug, element_id):
|
|||||||
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||||
placeholders = ",".join("?" * len(ids))
|
placeholders = ",".join("?" * len(ids))
|
||||||
count = conn.execute(
|
count = conn.execute(
|
||||||
f"SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
|
f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
|
||||||
ids + ids,
|
OR target_element_id IN ({placeholders})
|
||||||
|
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
|
||||||
|
ids + ids + ids + ids,
|
||||||
).fetchone()["c"]
|
).fetchone()["c"]
|
||||||
conn.close()
|
conn.close()
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -118,4 +118,9 @@ FLOW_NODE_FIELDS = {
|
|||||||
# Phase 3 — déclencheur clavier ("clavier") et minuteur récurrent
|
# Phase 3 — déclencheur clavier ("clavier") et minuteur récurrent
|
||||||
# ("minuteur"), voir TRIGGER_EVENTS ci-dessus.
|
# ("minuteur"), voir TRIGGER_EVENTS ci-dessus.
|
||||||
"trigger_key", "trigger_interval_ms",
|
"trigger_key", "trigger_interval_ms",
|
||||||
|
# Phase 5 — condition de collision (cond_source="collision", aux
|
||||||
|
# côtés de "objet"/"variable") : les deux éléments dont on compare les
|
||||||
|
# rectangles — voir evaluateConditionClause() dans
|
||||||
|
# static/js/play/conditions.js.
|
||||||
|
"cond_element_a", "cond_element_b",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,5 +105,16 @@ def ensure_flow_schema(slug):
|
|||||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_key TEXT")
|
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_key TEXT")
|
||||||
if "trigger_interval_ms" not in cols:
|
if "trigger_interval_ms" not in cols:
|
||||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_interval_ms INTEGER")
|
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_interval_ms INTEGER")
|
||||||
|
if "cond_element_a" not in cols:
|
||||||
|
# Phase 5 — condition de collision (cond_source="collision") : les
|
||||||
|
# deux éléments dont on compare les rectangles à l'écran. Pas de
|
||||||
|
# contrainte FK — même patron que block_id/trigger_custom_event_id
|
||||||
|
# ci-dessus (colonne ajoutée par ALTER TABLE, jamais contrainte
|
||||||
|
# dans ce fichier) : screens/elements/delete_element.py nettoie
|
||||||
|
# lui-même les nœuds qui référencent un élément supprimé (voir ce
|
||||||
|
# fichier), une contrainte ferait double emploi.
|
||||||
|
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_element_a INTEGER")
|
||||||
|
if "cond_element_b" not in cols:
|
||||||
|
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_element_b INTEGER")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ ELEMENT_ACTION_PROPERTIES = [
|
|||||||
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
|
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
|
||||||
# de se redéclencher tant que l'élément est désactivé).
|
# de se redéclencher tant que l'élément est désactivé).
|
||||||
("desactive", "Désactivé — grisé, ignore les clics"),
|
("desactive", "Désactivé — grisé, ignore les clics"),
|
||||||
|
# Phase 5 (moteur) — déplacement d'élément : "absolu" fixe la position
|
||||||
|
# (en % du cadre, comme Largeur/Hauteur ci-dessus — les éléments sont
|
||||||
|
# déjà positionnés ainsi, voir filters/element_style_filter.py) ;
|
||||||
|
# "relatif" AJOUTE la valeur à la position actuelle plutôt que de
|
||||||
|
# l'écraser (voir applyElementProperty() dans static/js/play/actions.js)
|
||||||
|
# — combiné à un déclencheur clavier (Phase 3), ça permet un
|
||||||
|
# personnage/objet qui se déplace pas à pas à chaque appui de touche.
|
||||||
|
("pos_x", "Position horizontale en % (absolue)"),
|
||||||
|
("pos_y", "Position verticale en % (absolue)"),
|
||||||
|
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
|
||||||
|
("pos_y_relatif", "Déplacer verticalement de... % (relatif)"),
|
||||||
]
|
]
|
||||||
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
|
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ var ELEMENT_PROPERTY_CSS = {
|
|||||||
border_color: { css: 'borderColor', suffix: '' },
|
border_color: { css: 'borderColor', suffix: '' },
|
||||||
width: { css: 'width', suffix: '%' },
|
width: { css: 'width', suffix: '%' },
|
||||||
height: { css: 'height', suffix: '%' },
|
height: { css: 'height', suffix: '%' },
|
||||||
|
// Phase 5 (moteur) — les éléments sont DÉJÀ positionnés en absolu avec
|
||||||
|
// left/top en % (voir filters/element_style_filter.py) : piloter ces
|
||||||
|
// deux propriétés est donc cohérent avec l'existant, pas une nouvelle
|
||||||
|
// mécanique de positionnement.
|
||||||
|
pos_x: { css: 'left', suffix: '%' },
|
||||||
|
pos_y: { css: 'top', suffix: '%' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyElementProperty(targetEl, property, value) {
|
function applyElementProperty(targetEl, property, value) {
|
||||||
@@ -70,6 +76,19 @@ function applyElementProperty(targetEl, property, value) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||||
|
// Phase 5 — AJOUTE la valeur à la position actuelle plutôt que de
|
||||||
|
// l'écraser (contrairement à pos_x/pos_y, "absolus") : combiné à un
|
||||||
|
// déclencheur clavier (Phase 3), ça permet un personnage/objet qui se
|
||||||
|
// déplace pas à pas à chaque appui de touche plutôt que de sauter
|
||||||
|
// toujours au même endroit.
|
||||||
|
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||||
|
const delta = parseFloat(value);
|
||||||
|
if (isNaN(delta)) return;
|
||||||
|
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||||
|
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||||
if (!spec || value === undefined || value === null) return;
|
if (!spec || value === undefined || value === null) return;
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
// Extrait de templates/play.html (voir plan de modularisation) : partie
|
// Extrait de templates/play.html (voir plan de modularisation) : partie
|
||||||
// PURE du moteur (aucun accès DOM) — lecture de champs/variables et
|
// PURE du moteur (aucun accès DOM) — lecture de champs/variables et
|
||||||
// comparaison, c'est la logique la plus amenée à grossir (nouvelles
|
// comparaison, c'est la logique la plus amenée à grossir (nouvelles
|
||||||
// opérations, condition de collision...), donc la plus utile à tester
|
// opérations...), donc la plus utile à tester (voir
|
||||||
// (voir static/js/play/__tests__/conditions.test.js).
|
// 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
|
// 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.
|
// du jeu (gameData.data), pour l'évaluation d'une condition.
|
||||||
@@ -97,10 +101,33 @@ function resolveVariablePath(rawValue, path) {
|
|||||||
// comportement historique), soit une VARIABLE GLOBALE (source
|
// comportement historique), soit une VARIABLE GLOBALE (source
|
||||||
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
|
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
|
||||||
// cond_variable_chemin).
|
// 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.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function evaluateConditionClause(clause) {
|
function evaluateConditionClause(clause) {
|
||||||
const source = clause.source ?? clause.cond_source ?? 'objet';
|
const source = clause.source ?? clause.cond_source ?? 'objet';
|
||||||
const operator = clause.operator ?? clause.cond_operator;
|
const operator = clause.operator ?? clause.cond_operator;
|
||||||
const expected = clause.value ?? clause.cond_value;
|
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') {
|
if (source === 'variable') {
|
||||||
const varName = clause.variable ?? clause.cond_variable;
|
const varName = clause.variable ?? clause.cond_variable;
|
||||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||||
|
|||||||
@@ -50,8 +50,18 @@ function nodeLabel(node) {
|
|||||||
var clauseSubject = function(c, field, variable) {
|
var clauseSubject = function(c, field, variable) {
|
||||||
return (c.source ?? c.cond_source) === 'variable' ? ('🔧 ' + (variable || c.cond_variable || '?')) : (field || c.cond_field || '?');
|
return (c.source ?? c.cond_source) === 'variable' ? ('🔧 ' + (variable || c.cond_variable || '?')) : (field || c.cond_field || '?');
|
||||||
};
|
};
|
||||||
var mainSubject = clauseSubject(node, node.cond_field, node.cond_variable);
|
// Phase 5 — condition de collision : pas de champ/opérateur/valeur,
|
||||||
var mainClause = '❓ SI ' + mainSubject + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
|
// juste les deux éléments comparés (voir evaluateConditionClause()
|
||||||
|
// dans static/js/play/conditions.js).
|
||||||
|
var clauseFullLabel = function(c, field, variable) {
|
||||||
|
if ((c.source ?? c.cond_source) === 'collision') {
|
||||||
|
var elA = ELEMENTS_LABELS[c.element_a ?? c.cond_element_a] || '?';
|
||||||
|
var elB = ELEMENTS_LABELS[c.element_b ?? c.cond_element_b] || '?';
|
||||||
|
return '💥 ' + elA + ' touche ' + elB;
|
||||||
|
}
|
||||||
|
return clauseSubject(c, field, variable) + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator ?? c.cond_operator] || (c.operator ?? c.cond_operator)) + ' ' + ((c.value ?? c.cond_value) || '');
|
||||||
|
};
|
||||||
|
var mainClause = '❓ SI ' + clauseFullLabel(node, node.cond_field, node.cond_variable);
|
||||||
if (node.cond_clauses) {
|
if (node.cond_clauses) {
|
||||||
// 2.4 — plusieurs clauses combinées : on affiche chaque champ/variable
|
// 2.4 — plusieurs clauses combinées : on affiche chaque champ/variable
|
||||||
// testé, relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
|
// testé, relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
|
||||||
@@ -59,7 +69,7 @@ function nodeLabel(node) {
|
|||||||
var clauses = JSON.parse(node.cond_clauses);
|
var clauses = JSON.parse(node.cond_clauses);
|
||||||
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
|
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
|
||||||
return '❓ SI ' + clauses.map(function(c) {
|
return '❓ SI ' + clauses.map(function(c) {
|
||||||
return clauseSubject(c, c.field, c.variable) + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
|
return clauseFullLabel(c, c.field, c.variable);
|
||||||
}).join(combLabel);
|
}).join(combLabel);
|
||||||
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
|
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
|
||||||
}
|
}
|
||||||
@@ -363,11 +373,18 @@ function onFlowFieldChange() {
|
|||||||
// pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus
|
// pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus
|
||||||
// bas, sur le même principe mais scopée par index.
|
// bas, sur le même principe mais scopée par index.
|
||||||
function onFlowConditionSourceChange() {
|
function onFlowConditionSourceChange() {
|
||||||
var isVariable = document.getElementById('nf-cond-source').value === 'variable';
|
var source = document.getElementById('nf-cond-source').value;
|
||||||
document.getElementById('nf-cond-objet-fields').style.display = isVariable ? 'none' : 'block';
|
var isVariable = source === 'variable';
|
||||||
|
var isCollision = source === 'collision';
|
||||||
|
document.getElementById('nf-cond-objet-fields').style.display = (isVariable || isCollision) ? 'none' : 'block';
|
||||||
document.getElementById('nf-cond-variable-fields').style.display = isVariable ? 'block' : 'none';
|
document.getElementById('nf-cond-variable-fields').style.display = isVariable ? 'block' : 'none';
|
||||||
var hint = document.getElementById('nf-cond-no-objet-hint');
|
var hint = document.getElementById('nf-cond-no-objet-hint');
|
||||||
if (hint) hint.style.display = isVariable ? 'none' : 'block';
|
if (hint) hint.style.display = (isVariable || isCollision) ? 'none' : 'block';
|
||||||
|
// Collision (Phase 5) : ni opérateur ni valeur de comparaison — le
|
||||||
|
// chevauchement EST directement le booléen vrai/faux du nœud (voir
|
||||||
|
// evaluateConditionClause() dans static/js/play/conditions.js).
|
||||||
|
document.getElementById('nf-cond-collision-fields').style.display = isCollision ? 'block' : 'none';
|
||||||
|
document.getElementById('nf-cond-operator-value-row').style.display = isCollision ? 'none' : 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
|
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
|
||||||
@@ -572,20 +589,30 @@ function submitNodeForm() {
|
|||||||
} else if (currentNodeFormType === 'condition') {
|
} else if (currentNodeFormType === 'condition') {
|
||||||
var condSource = document.getElementById('nf-cond-source').value;
|
var condSource = document.getElementById('nf-cond-source').value;
|
||||||
payload.cond_source = condSource;
|
payload.cond_source = condSource;
|
||||||
payload.cond_operator = document.getElementById('nf-cond-operator').value;
|
if (condSource === 'collision') {
|
||||||
payload.cond_value = document.getElementById('nf-cond-value').value;
|
// Phase 5 — ni opérateur ni valeur de comparaison pour une
|
||||||
if (condSource === 'variable') {
|
// collision (voir onFlowConditionSourceChange()) : juste les deux
|
||||||
payload.cond_variable = document.getElementById('nf-cond-variable').value;
|
// éléments dont on compare les rectangles. Pas de clauses combinées
|
||||||
payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value;
|
// (ET/OU) pour une collision — même limitation assumée que pour les
|
||||||
|
// autres sources tant qu'aucune clause supplémentaire n'a été ajoutée.
|
||||||
|
payload.cond_element_a = parseInt(document.getElementById('nf-cond-element-a').value, 10);
|
||||||
|
payload.cond_element_b = parseInt(document.getElementById('nf-cond-element-b').value, 10);
|
||||||
} else {
|
} else {
|
||||||
var fieldSel = document.getElementById('nf-cond-field');
|
payload.cond_operator = document.getElementById('nf-cond-operator').value;
|
||||||
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
|
payload.cond_value = document.getElementById('nf-cond-value').value;
|
||||||
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
|
if (condSource === 'variable') {
|
||||||
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
|
payload.cond_variable = document.getElementById('nf-cond-variable').value;
|
||||||
payload.cond_field = fieldSel.value;
|
payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value;
|
||||||
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
|
} else {
|
||||||
|
var fieldSel = document.getElementById('nf-cond-field');
|
||||||
|
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
|
||||||
|
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
|
||||||
|
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
|
||||||
|
payload.cond_field = fieldSel.value;
|
||||||
|
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (flowConditionExtraClauses.length) {
|
if (condSource !== 'collision' && flowConditionExtraClauses.length) {
|
||||||
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
|
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
|
||||||
// la première clause de la liste, suivie des clauses supplémentaires.
|
// la première clause de la liste, suivie des clauses supplémentaires.
|
||||||
var mainClause = condSource === 'variable'
|
var mainClause = condSource === 'variable'
|
||||||
|
|||||||
@@ -307,6 +307,7 @@
|
|||||||
<label>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
|
<label>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
|
||||||
<option value="objet">Champ d'un objet</option>
|
<option value="objet">Champ d'un objet</option>
|
||||||
<option value="variable">Variable globale</option>
|
<option value="variable">Variable globale</option>
|
||||||
|
<option value="collision">Collision entre deux éléments</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
|
|
||||||
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
|
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
|
||||||
@@ -338,12 +339,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-cond-collision-fields" style="display:none;">
|
||||||
|
<label>Premier élément<select id="nf-cond-element-a">
|
||||||
|
{% for e2 in elements %}
|
||||||
|
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<label>Deuxième élément<select id="nf-cond-element-b">
|
||||||
|
{% for e2 in elements %}
|
||||||
|
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></label>
|
||||||
|
<p class="hint">Vrai si les deux élément se touchent (leurs rectangles se chevauchent à l'écran) — relie le port "Faux" du nœud pour "ne se touchent pas".</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nf-cond-operator-value-row">
|
||||||
<label>Condition<select id="nf-cond-operator">
|
<label>Condition<select id="nf-cond-operator">
|
||||||
{% for key, label in condition_operators %}
|
{% for key, label in condition_operators %}
|
||||||
<option value="{{ key }}">{{ label }}</option>
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select></label>
|
</select></label>
|
||||||
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
|
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="nf-cond-extra-wrap">
|
<div id="nf-cond-extra-wrap">
|
||||||
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
|
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Position/déplacement d'élément + condition de collision — Phase 5 du
|
||||||
|
plan d'extension du moteur. Comme le reste du graphe de logique côté
|
||||||
|
client, le comportement RÉEL (déplacement CSS effectif, chevauchement de
|
||||||
|
rectangles via getBoundingClientRect) n'est pas testable sans navigateur
|
||||||
|
(voir play_js_bundle() — on vérifie que la logique existe dans le JS
|
||||||
|
livré et que les nœuds persistent leurs champs, pas qu'ils s'exécutent)."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from conftest import play_js_bundle, screen_edit_js_bundle
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
|
||||||
|
def _create_element(client, slug, widget="conteneur"):
|
||||||
|
resp = client.post(f"/game/{slug}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||||
|
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||||
|
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||||
|
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
return screen_id, element_id
|
||||||
|
|
||||||
|
|
||||||
|
def _add_flow_node(client, slug, screen_id, **fields):
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
||||||
|
data=json.dumps(fields),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_action_node_persists_absolute_property(client, game):
|
||||||
|
screen_id, element_id = _create_element(client, game)
|
||||||
|
node_id = _add_flow_node(
|
||||||
|
client, game, screen_id, node_type="action", action_type="modifier_element",
|
||||||
|
target_element_id=element_id, element_property="pos_x", element_value="42",
|
||||||
|
)
|
||||||
|
node = screens.get_flow_node(game, node_id)
|
||||||
|
assert node["element_property"] == "pos_x"
|
||||||
|
assert node["element_value"] == "42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_action_node_persists_relative_property(client, game):
|
||||||
|
screen_id, element_id = _create_element(client, game)
|
||||||
|
node_id = _add_flow_node(
|
||||||
|
client, game, screen_id, node_type="action", action_type="modifier_element",
|
||||||
|
target_element_id=element_id, element_property="pos_y_relatif", element_value="-5",
|
||||||
|
)
|
||||||
|
node = screens.get_flow_node(game, node_id)
|
||||||
|
assert node["element_property"] == "pos_y_relatif"
|
||||||
|
assert node["element_value"] == "-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collision_condition_node_persists_both_elements(client, game):
|
||||||
|
screen_id, element_a = _create_element(client, game)
|
||||||
|
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False)
|
||||||
|
element_b = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
node_id = _add_flow_node(
|
||||||
|
client, game, screen_id, node_type="condition", cond_source="collision",
|
||||||
|
cond_element_a=element_a, cond_element_b=element_b,
|
||||||
|
)
|
||||||
|
node = screens.get_flow_node(game, node_id)
|
||||||
|
assert node["cond_source"] == "collision"
|
||||||
|
assert node["cond_element_a"] == element_a
|
||||||
|
assert node["cond_element_b"] == element_b
|
||||||
|
assert node["cond_field"] is None
|
||||||
|
assert node["cond_operator"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_element_cleans_up_collision_condition_nodes(client, game):
|
||||||
|
"""Un nœud de collision qui référence un élément supprimé n'a plus de
|
||||||
|
sens (voir delete_element.py) — même nettoyage que pour un déclencheur/
|
||||||
|
une action ciblant un élément supprimé."""
|
||||||
|
screen_id, element_a = _create_element(client, game)
|
||||||
|
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False)
|
||||||
|
element_b = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
node_id = _add_flow_node(
|
||||||
|
client, game, screen_id, node_type="condition", cond_source="collision",
|
||||||
|
cond_element_a=element_a, cond_element_b=element_b,
|
||||||
|
)
|
||||||
|
client.post(f"/game/{game}/elements/{element_a}/delete", follow_redirects=False)
|
||||||
|
assert screens.get_flow_node(game, node_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_page_exposes_position_and_collision_runtime(client, game):
|
||||||
|
js = play_js_bundle()
|
||||||
|
assert "pos_x" in js
|
||||||
|
assert "pos_y" in js
|
||||||
|
assert "elementsOverlap" in js
|
||||||
|
assert "getBoundingClientRect" in js
|
||||||
|
assert "'collision'" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_edit_exposes_collision_condition_editor(client, game):
|
||||||
|
html = client.get(f"/game/{game}/screens/{_create_element(client, game)[0]}/edit").get_data(as_text=True)
|
||||||
|
assert "nf-cond-collision-fields" in html
|
||||||
|
assert "nf-cond-element-a" in html
|
||||||
|
assert "nf-cond-element-b" in html
|
||||||
|
js = screen_edit_js_bundle()
|
||||||
|
assert "isCollision" in js
|
||||||
|
assert "cond_element_a" in js
|
||||||
Reference in New Issue
Block a user