From 9e263435e6c494b57d2e420c1dc014edf827ead1 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 17:34:51 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205=20:=20position/d=C3=A9placement=20d'?= =?UTF-8?q?=C3=A9l=C3=A9ment=20+=20condition=20de=20collision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- screens/elements/delete_element.py | 35 +++--- .../flow_nodes_referencing_element.py | 18 ++-- screens/flow/constants.py | 5 + screens/flow/ensure_flow_schema.py | 11 ++ screens/labels/element_action_properties.py | 11 ++ static/js/play/actions.js | 19 ++++ static/js/play/conditions.js | 31 +++++- static/js/screen_edit/flow-editor.js | 63 +++++++---- templates/screen_edit.html | 17 +++ tests/test_position_and_collision.py | 102 ++++++++++++++++++ 10 files changed, 271 insertions(+), 41 deletions(-) create mode 100644 tests/test_position_and_collision.py diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py index b1d37aac..8a769a87 100644 --- a/screens/elements/delete_element.py +++ b/screens/elements/delete_element.py @@ -14,20 +14,29 @@ def delete_element(slug, element_id): ids = element_descendant_ids(conn, row["screen_id"], element_id) placeholders = ",".join("?" * len(ids)) # 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 - # système _actions (conservé pour compatibilité) qui référence - # l'élément supprimé OU L'UN DE SES DESCENDANTS n'a plus aucun sens une - # fois l'élément disparu — et ces colonnes (trigger_element_id/ - # target_element_id) n'ont volontairement PAS de ON DELETE CASCADE (un - # élément ne doit pas pouvoir être supprimé "par erreur" en cascade - # depuis un nœud de logique qu'on modifie). Sans ce nettoyage préalable, - # PRAGMA foreign_keys=ON (voir db/connection.py) fait échouer la - # suppression elle-même avec "FOREIGN KEY constraint failed". Voir - # element_delete_impact.py pour prévenir l'utilisateur AVANT qu'il - # confirme, plutôt que de supprimer ces nœuds en silence. + # élément"/action "Modifier cet élément"/condition de collision — voir + # ci-dessous...) ou une ancienne action du système _actions (conservé + # pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES + # DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces + # colonnes (trigger_element_id/target_element_id) n'ont volontairement + # PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être + # supprimé "par erreur" en cascade depuis un nœud de logique qu'on + # modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir + # db/connection.py) fait échouer la suppression elle-même avec + # "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour + # 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( - f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})", - ids + ids, + f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) + 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("DELETE FROM _screen_elements WHERE id = ?", (element_id,)) diff --git a/screens/elements/flow_nodes_referencing_element.py b/screens/elements/flow_nodes_referencing_element.py index ec7257f4..9231149a 100644 --- a/screens/elements/flow_nodes_referencing_element.py +++ b/screens/elements/flow_nodes_referencing_element.py @@ -6,12 +6,12 @@ from .element_descendant_ids import element_descendant_ids def flow_nodes_referencing_element(slug, element_id): """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 - référencent cet élément OU L'UN DE SES DESCENDANTS — donc qui seraient - supprimés SI on supprimait cet élément (voir delete_element.py). Utilisé - pour prévenir l'utilisateur AVANT qu'il confirme la suppression (voir - la modale de confirmation dans screen_edit.html), plutôt que de les - supprimer en silence.""" + élément", action "Modifier cet élément"/"Activer cet onglet", condition + de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES + DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément + (voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT + qu'il confirme la suppression (voir la modale de confirmation dans + screen_edit.html), plutôt que de les supprimer en silence.""" ensure_flow_schema(slug) conn = db.connect(slug) 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) placeholders = ",".join("?" * len(ids)) count = conn.execute( - f"SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})", - ids + ids, + f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) + OR target_element_id IN ({placeholders}) + OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""", + ids + ids + ids + ids, ).fetchone()["c"] conn.close() return count diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 786797a5..17fed736 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -118,4 +118,9 @@ FLOW_NODE_FIELDS = { # Phase 3 — déclencheur clavier ("clavier") et minuteur récurrent # ("minuteur"), voir TRIGGER_EVENTS ci-dessus. "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", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index 46dff952..8bf739ec 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -105,5 +105,16 @@ def ensure_flow_schema(slug): conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_key TEXT") if "trigger_interval_ms" not in cols: 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.close() diff --git a/screens/labels/element_action_properties.py b/screens/labels/element_action_properties.py index 84da90b2..36633dbe 100644 --- a/screens/labels/element_action_properties.py +++ b/screens/labels/element_action_properties.py @@ -28,6 +28,17 @@ ELEMENT_ACTION_PROPERTIES = [ # 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é). ("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) diff --git a/static/js/play/actions.js b/static/js/play/actions.js index a603a722..afddc87e 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -12,6 +12,12 @@ var ELEMENT_PROPERTY_CSS = { border_color: { css: 'borderColor', suffix: '' }, width: { css: 'width', 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) { @@ -70,6 +76,19 @@ function applyElementProperty(targetEl, property, value) { } 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]; if (!spec || value === undefined || value === null) return; diff --git a/static/js/play/conditions.js b/static/js/play/conditions.js index 6205fe41..8aa7b550 100644 --- a/static/js/play/conditions.js +++ b/static/js/play/conditions.js @@ -2,8 +2,12 @@ // 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, condition de collision...), donc la plus utile à tester -// (voir static/js/play/__tests__/conditions.test.js). +// 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. @@ -97,10 +101,33 @@ 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. +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) { 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; diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index da36760f..5a68295d 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -50,8 +50,18 @@ function nodeLabel(node) { var clauseSubject = function(c, field, variable) { 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); - var mainClause = '❓ SI ' + mainSubject + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || ''); + // Phase 5 — condition de collision : pas de champ/opérateur/valeur, + // 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) { // 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. @@ -59,7 +69,7 @@ function nodeLabel(node) { var clauses = JSON.parse(node.cond_clauses); var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET '; 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); } catch (e) { /* JSON invalide : on retombe sur la clause historique */ } } @@ -363,11 +373,18 @@ function onFlowFieldChange() { // pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus // bas, sur le même principe mais scopée par index. function onFlowConditionSourceChange() { - var isVariable = document.getElementById('nf-cond-source').value === 'variable'; - document.getElementById('nf-cond-objet-fields').style.display = isVariable ? 'none' : 'block'; + var source = document.getElementById('nf-cond-source').value; + 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'; 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 ---------- @@ -572,20 +589,30 @@ function submitNodeForm() { } else if (currentNodeFormType === 'condition') { var condSource = document.getElementById('nf-cond-source').value; payload.cond_source = condSource; - payload.cond_operator = document.getElementById('nf-cond-operator').value; - payload.cond_value = document.getElementById('nf-cond-value').value; - if (condSource === 'variable') { - payload.cond_variable = document.getElementById('nf-cond-variable').value; - payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value; + if (condSource === 'collision') { + // Phase 5 — ni opérateur ni valeur de comparaison pour une + // collision (voir onFlowConditionSourceChange()) : juste les deux + // éléments dont on compare les rectangles. Pas de clauses combinées + // (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 { - 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 : ''; + payload.cond_operator = document.getElementById('nf-cond-operator').value; + payload.cond_value = document.getElementById('nf-cond-value').value; + if (condSource === 'variable') { + payload.cond_variable = document.getElementById('nf-cond-variable').value; + payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value; + } 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 // la première clause de la liste, suivie des clauses supplémentaires. var mainClause = condSource === 'variable' diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 94e9d4c6..de37dccf 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -307,6 +307,7 @@
@@ -338,12 +339,28 @@ {% endif %}
+ + +
+