Phase 3 : déclencheur clavier + minuteur récurrent
Build and deploy / test-python (push) Successful in 1m28s
Build and deploy / test-js (push) Successful in 7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

screens/flow/constants.py : TRIGGER_EVENTS += "clavier" (À l'appui sur
une touche) et "minuteur" (Toutes les X millisecondes) — ni élément ni
écran précis pour les deux, comme "evenement" déjà en place.
FLOW_NODE_FIELDS += trigger_key/trigger_interval_ms. ensure_flow_schema.py :
ALTER TABLE pour les 2 colonnes (patron trigger_custom_event_id).

templates/screen_edit.html + static/js/screen_edit/flow-editor.js :
- "clavier" : un champ "Touche à surveiller" qui capture lui-même la
  touche pressée (onkeydown sur l'input, captureFlowTriggerKey()) plutôt
  que de faire deviner la syntaxe attendue (ev.key du navigateur, ex.
  "ArrowUp", "a", " " pour Espace).
- "minuteur" : un simple champ numérique (millisecondes).
- nodeLabel() affiche "⌨️ Touche « X »"/"⏱️ Toutes les N ms" sur le nœud.

static/js/play/triggers.js (moteur de jeu) :
- bindKeyboardTriggers() : UN SEUL window.addEventListener('keydown', ...)
  posé une fois pour tout le jeu (voir l'amorçage en fin de
  templates/play.html) — même patron de scan global que
  dispatchGameEvent() pour "Sur un événement personnalisé".
- runScreenTimerTriggers(screenId) : géré PAR ÉCRAN (appelé depuis
  showScreen(), static/js/play/screens.js) — démarre les setInterval des
  nœuds "minuteur" de l'écran affiché, arrête d'abord tous ceux de
  l'affichage précédent (même principe que runAnimationTimeline) pour
  ne jamais accumuler des minuteurs sur des écrans quittés.

Vérifié : 255 tests passent (5 nouveaux, dont un qui verrouille que la
touche Espace — très probablement utilisée en jeu — n'est pas filtrée
comme une valeur "vide" par add_flow_node.py), 13 tests node:test
toujours au vert, syntaxe JS validée sur tous les fichiers de
static/js/play/ et static/js/screen_edit/. Comme le reste du graphe de
logique côté client, le comportement RÉEL d'un keydown/setInterval n'est
pas testable sans navigateur — test manuel recommandé (touche assignée
à un saut, minuteur faisant avancer un compteur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 17:05:55 +02:00
co-authored by Claude Sonnet 5
parent 7476ed229e
commit 727c3c97ba
9 changed files with 185 additions and 2 deletions
+16
View File
@@ -50,6 +50,19 @@ TRIGGER_EVENTS = [
# gameData.flows côté client (voir dispatchGameEvent() dans
# templates/play.html), au même titre que findTriggerNode().
("evenement", "Sur un événement personnalisé"),
# Phase 3 (moteur) — ni élément ni écran précis : trigger_key (quelle
# touche, ev.key du clavier) est le seul réglage propre à ce nœud,
# écouté UNE SEULE FOIS pour tout le jeu (voir bindKeyboardTriggers()
# dans static/js/play/triggers.js), même patron de scan global que
# "evenement" ci-dessus.
("clavier", "À l'appui sur une touche"),
# Contrairement aux autres déclencheurs (réagissent à quelque chose),
# celui-ci fait AVANCER le jeu tout seul, à intervalle régulier —
# trigger_interval_ms (en millisecondes) est son seul réglage. Géré
# PAR ÉCRAN (voir showScreen() dans static/js/play/screens.js) : ne
# tourne que tant que l'écran qui le porte est affiché, jamais en
# arrière-plan sur un écran quitté.
("minuteur", "Toutes les X millisecondes"),
]
CONDITION_OPERATORS = [
@@ -91,4 +104,7 @@ FLOW_NODE_FIELDS = {
# recrée puis supprime l'ancien) — le client doit donc renvoyer
# block_id à chaque (ré)création, pas juste le poser une fois.
"block_id",
# Phase 3 — déclencheur clavier ("clavier") et minuteur récurrent
# ("minuteur"), voir TRIGGER_EVENTS ci-dessus.
"trigger_key", "trigger_interval_ms",
}
+9
View File
@@ -96,5 +96,14 @@ def ensure_flow_schema(slug):
# rattache paresseusement à un bloc "Bloc principal" généré à la
# volée, la première fois que l'onglet Blocs est ouvert.
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN block_id INTEGER")
if "trigger_key" not in cols:
# Phase 3 — déclencheur clavier (trigger_event="clavier") : quelle
# touche (ev.key du navigateur, ex. "ArrowUp", " ", "a") ; minuteur
# récurrent (trigger_event="minuteur") : trigger_interval_ms, en
# millisecondes entre deux déclenchements — voir TRIGGER_EVENTS
# (screens/flow/constants.py) pour le détail des deux.
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")
conn.commit()
conn.close()
+2
View File
@@ -5,4 +5,6 @@ TRIGGER_LABELS = {
"survol": "Au survol",
"fin_survol": "Fin du survol",
"evenement": "Sur un événement personnalisé",
"clavier": "À l'appui sur une touche",
"minuteur": "Toutes les X millisecondes",
}
+1
View File
@@ -39,6 +39,7 @@ function showScreen(id) {
applyScreenAspect(el.dataset.aspect || '9/16');
}
runScreenShowTriggers(id);
runScreenTimerTriggers(id);
runAnimationTimeline(id);
}
+40
View File
@@ -16,6 +16,29 @@ function runScreenShowTriggers(screenId) {
.forEach(function(n){ runFlowFrom(n.id); });
}
// Minuteur récurrent (Phase 3) : contrairement aux autres déclencheurs
// (réagissent à quelque chose), celui-ci fait avancer le jeu tout seul, à
// intervalle régulier — géré PAR ÉCRAN (appelé depuis showScreen(),
// static/js/play/screens.js) : n'existe QUE tant que l'écran qui le
// porte est affiché. On arrête d'abord tout minuteur de l'affichage
// précédent (même principe que runAnimationTimeline) — sans ça, revenir
// plusieurs fois sur le même écran accumulerait des setInterval en
// double, chacun exécutant le graphe en plus des autres.
var activeTimerTriggerIntervals = [];
function runScreenTimerTriggers(screenId) {
activeTimerTriggerIntervals.forEach(function(id) { clearInterval(id); });
activeTimerTriggerIntervals = [];
const flow = gameData.flows[String(screenId)];
if (!flow) return;
flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0; })
.forEach(function(n){
var intervalId = setInterval(function(){ runFlowFrom(n.id, screenId); }, n.trigger_interval_ms);
activeTimerTriggerIntervals.push(intervalId);
});
}
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
// TOUS les nœuds Déclencheur qui lui sont rattachés — ce sont les points
// d'entrée des graphes à exécuter. Renvoie un TABLEAU (jamais un seul
@@ -130,3 +153,20 @@ function bindHoverTriggers() {
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
});
}
// Déclencheur clavier (Phase 3) : ni élément ni écran précis — un SEUL
// écouteur posé une fois pour tout le jeu (voir l'amorçage en fin de
// play.html), même principe de scan global que dispatchGameEvent()
// (flow-engine.js) pour "Sur un événement personnalisé" : gameData.flows
// est parcouru à CHAQUE appui, pas une seule fois au chargement, pour
// que les nœuds ajoutés/déplacés après un rafraîchissement des données
// restent pris en compte sans devoir ré-attacher quoi que ce soit.
function bindKeyboardTriggers() {
window.addEventListener('keydown', function (ev) {
for (const screenIdKey in gameData.flows) {
gameData.flows[screenIdKey].nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key; })
.forEach(function(n){ runFlowFrom(n.id, screenIdKey); });
}
});
}
+28 -2
View File
@@ -37,6 +37,12 @@ function nodeLabel(node) {
var listenedEvent = CUSTOM_EVENTS_MAP[node.trigger_custom_event_id];
return '📣 Sur « ' + (listenedEvent ? listenedEvent.name : '?') + ' »';
}
if (node.trigger_event === 'clavier') {
return '⌨️ Touche « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »';
}
if (node.trigger_event === 'minuteur') {
return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms';
}
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
}
@@ -278,10 +284,26 @@ function toggleFlowTriggerFields() {
// l'écran entier : c'est un scan global (voir dispatchGameEvent() dans
// play.html), le seul réglage propre à ce nœud est QUEL événement.
var isCustomEvent = eventVal === 'evenement';
document.getElementById('nf-trigger-element-row').style.display = (isAffichage || isCustomEvent) ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = (isAffichage || isCustomEvent) ? 'none' : 'block';
// Idem "clavier"/"minuteur" (Phase 3) : ni élément ni écran précis.
var isKeyboard = eventVal === 'clavier';
var isTimer = eventVal === 'minuteur';
var noElement = isAffichage || isCustomEvent || isKeyboard || isTimer;
document.getElementById('nf-trigger-element-row').style.display = noElement ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = noElement ? 'none' : 'block';
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
document.getElementById('nf-trigger-event-row').style.display = isCustomEvent ? 'block' : 'none';
document.getElementById('nf-trigger-key-row').style.display = isKeyboard ? 'block' : 'none';
document.getElementById('nf-trigger-interval-row').style.display = isTimer ? 'block' : 'none';
}
// Capture la touche pressée dans le champ "Touche à surveiller" (Phase 3)
// — évite de faire deviner la syntaxe attendue (ev.key du navigateur,
// ex. "ArrowUp", "a", " " pour la barre d'espace) : l'input lui-même
// écoute keydown et se remplit tout seul, jamais tapé à la main.
function captureFlowTriggerKey(event) {
event.preventDefault();
document.getElementById('nf-trigger-key').value = event.key;
document.getElementById('nf-trigger-key-display').value = event.key === ' ' ? 'Espace' : event.key;
}
function closeNodeForm() {
@@ -527,6 +549,10 @@ function submitNodeForm() {
payload.trigger_event = document.getElementById('nf-trigger-event').value;
if (payload.trigger_event === 'evenement') {
payload.trigger_custom_event_id = parseInt(document.getElementById('nf-trigger-custom-event').value, 10);
} else if (payload.trigger_event === 'clavier') {
payload.trigger_key = document.getElementById('nf-trigger-key').value;
} else if (payload.trigger_event === 'minuteur') {
payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10);
} else if (payload.trigger_event !== 'affichage') {
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
}
+4
View File
@@ -116,6 +116,10 @@
bindClicks();
bindHoverTexts();
bindHoverTriggers();
// Un seul écouteur clavier pour tout le jeu (Phase 3) — showScreen()
// ci-dessus a déjà démarré les minuteurs de l'écran affiché au premier
// affichage (runScreenTimerTriggers, appelé depuis showScreen()).
bindKeyboardTriggers();
</script>
</body>
</html>
+11
View File
@@ -290,6 +290,17 @@
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% endif %}
</div>
<div id="nf-trigger-key-row" style="display:none;">
<label>Touche à surveiller
<input type="text" id="nf-trigger-key-display" readonly placeholder="Clique ici puis appuie sur une touche" onkeydown="captureFlowTriggerKey(event)">
</label>
<input type="hidden" id="nf-trigger-key">
</div>
<div id="nf-trigger-interval-row" style="display:none;">
<label>Toutes les... millisecondes
<input type="number" id="nf-trigger-interval" min="50" step="50" value="1000" placeholder="ex. 1000">
</label>
</div>
</div>
<div id="nodeFormCondition" style="display:none;">
+74
View File
@@ -0,0 +1,74 @@
"""Tests du déclencheur clavier ("clavier") et du minuteur récurrent
("minuteur") — Phase 3 du plan d'extension du moteur. Comme le reste du
graphe de logique côté client, le comportement RÉEL d'un vrai keydown/
setInterval n'est pas testable sans navigateur (voir play_js_bundle() —
on vérifie que la logique existe dans le JS livré, pas qu'elle s'exécute)."""
import json
import re
from conftest import play_js_bundle, screen_edit_js_bundle
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
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_keyboard_trigger_node_persists_key(client, game):
screen_id = _create_screen(client, game)
node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="clavier", trigger_key="ArrowUp")
node = screens.get_flow_node(game, node_id)
assert node["trigger_event"] == "clavier"
assert node["trigger_key"] == "ArrowUp"
assert node["trigger_element_id"] is None
def test_keyboard_trigger_persists_space_key(client, game):
"""La barre d'espace (ev.key === ' ', un seul caractère espace) ne doit
PAS être traitée comme une valeur "vide" par add_flow_node.py (qui
filtre None/"" — " " n'est ni l'un ni l'autre) : une touche très
probablement utilisée ("Espace pour sauter") doit bien se persister."""
screen_id = _create_screen(client, game)
node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="clavier", trigger_key=" ")
node = screens.get_flow_node(game, node_id)
assert node["trigger_key"] == " "
def test_timer_trigger_node_persists_interval(client, game):
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="trigger", trigger_event="minuteur", trigger_interval_ms=500,
)
node = screens.get_flow_node(game, node_id)
assert node["trigger_event"] == "minuteur"
assert node["trigger_interval_ms"] == 500
def test_play_page_exposes_keyboard_and_timer_trigger_runtime(client, game):
js = play_js_bundle()
assert "bindKeyboardTriggers" in js
assert "'clavier'" in js
assert "runScreenTimerTriggers" in js
assert "'minuteur'" in js
assert "trigger_interval_ms" in js
def test_screen_edit_exposes_keyboard_and_timer_trigger_editor(client, game):
html = client.get(f"/game/{game}/screens/{_create_screen(client, game)}/edit").get_data(as_text=True)
assert "nf-trigger-key-row" in html
assert "nf-trigger-interval-row" in html
js = screen_edit_js_bundle()
assert "captureFlowTriggerKey" in js
assert "trigger_interval_ms" in js