Phase 5 : position/déplacement d'élément + condition de collision
Build and deploy / test-python (push) Successful in 1m29s
Build and deploy / test-js (push) Successful in 7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-08-30 17:34:51 +02:00
co-authored by Claude Sonnet 5
parent 92fbfbc9dd
commit 9e263435e6
10 changed files with 271 additions and 41 deletions
+22 -13
View File
@@ -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
+5
View File
@@ -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",
} }
+11
View File
@@ -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)
+19
View File
@@ -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;
+29 -2
View File
@@ -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;
+45 -18
View File
@@ -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'
+17
View File
@@ -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
+102
View File
@@ -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