Merge pull request 'Dev' (#6) from dev into main
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -2,7 +2,14 @@ name: Build and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
|
||||
# Le job "test" tourne sur CHAQUE push (main et dev) : jusqu'ici aucune
|
||||
# étape de CI n'exécutait la suite de tests, rien n'empêchait un commit
|
||||
# cassé d'atteindre la production (voir l'audit qualité de la Phase 0 du
|
||||
# plan). "build-and-push"/"deploy", eux, restent réservés à main (via le
|
||||
# filtre "if" sur gitea.ref) — un push sur dev ne doit jamais redéployer
|
||||
# la prod, seulement faire tourner les tests.
|
||||
|
||||
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
||||
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
||||
@@ -19,7 +26,32 @@ on:
|
||||
# github.com / du miroir gitea.com depuis le runner self-hosted.
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Conteneurs officiels plutôt que actions/setup-python|setup-node du
|
||||
# marketplace — même logique que le reste de ce fichier (éviter une
|
||||
# dépendance à la disponibilité de github.com/du miroir gitea.com
|
||||
# depuis le runner self-hosted), et le runner n'a pas besoin
|
||||
# d'installer quoi que ce soit d'autre que Docker, déjà requis pour
|
||||
# build-and-push.
|
||||
- name: Tests Python (pytest)
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/app -w /app python:3.13-slim sh -c "
|
||||
pip install --quiet -r requirements-dev.txt &&
|
||||
python -m pytest tests/ -q
|
||||
"
|
||||
|
||||
- name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation)
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/app -w /app node:20-slim \
|
||||
node --test static/js/play/__tests__/*.test.js
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -41,6 +73,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -3,8 +3,10 @@ import screens
|
||||
from core.flask_app import app
|
||||
from filters.colname_filter import _colname
|
||||
from filters.element_style_filter import _element_style, _element_transform_style
|
||||
from filters.truncate_sentences_filter import _truncate_sentences
|
||||
|
||||
app.jinja_env.filters["colname"] = _colname
|
||||
app.jinja_env.filters["elstyle"] = _element_style
|
||||
app.jinja_env.filters["eltransform"] = _element_transform_style
|
||||
app.jinja_env.filters["elabel"] = screens.element_label
|
||||
app.jinja_env.filters["truncatesentences"] = _truncate_sentences
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import re
|
||||
|
||||
_SENTENCE_END = re.compile(r"[.!?](?:\s+|$)")
|
||||
|
||||
|
||||
def _truncate_sentences(text, n=3):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
matches = list(_SENTENCE_END.finditer(text))
|
||||
if len(matches) <= n:
|
||||
return text
|
||||
cutoff = matches[n - 1].end()
|
||||
return text[:cutoff].rstrip() + " …"
|
||||
@@ -23,7 +23,7 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR)
|
||||
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
|
||||
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
||||
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
|
||||
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"]
|
||||
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play"]
|
||||
|
||||
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events
|
||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks
|
||||
|
||||
@@ -13,5 +13,14 @@ def flow_edge_add(slug, screen_id):
|
||||
to_node_id = int(payload["to_node_id"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return jsonify({"error": "nœuds invalides"}), 400
|
||||
from_node = screens.get_flow_node(slug, from_node_id)
|
||||
to_node = screens.get_flow_node(slug, to_node_id)
|
||||
if not from_node or not to_node:
|
||||
return jsonify({"error": "nœuds invalides"}), 400
|
||||
if from_node.get("block_id") != to_node.get("block_id"):
|
||||
# Un bloc de logique est un automate fermé (voir screens/flow/blocks/) :
|
||||
# aucun câble entre deux blocs différents, toute communication passe
|
||||
# par les événements personnalisés (declencher_evenement).
|
||||
return jsonify({"error": "Impossible de relier deux nœuds de blocs différents"}), 400
|
||||
edge_id = screens.add_flow_edge(slug, screen_id, from_node_id, payload.get("from_port", "out"), to_node_id)
|
||||
return jsonify({"id": edge_id})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import flow_block_new, flow_block_edit, flow_block_delete
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/<int:block_id>/delete", methods=["POST"])
|
||||
def flow_block_delete(slug, screen_id, block_id):
|
||||
"""Supprime le bloc ET tous ses nœuds/arêtes (voir
|
||||
screens/flow/blocks/delete_flow_block.py) — confirmation côté client
|
||||
avant l'envoi, affichant le nombre de nœuds concernés."""
|
||||
screens.delete_flow_block(slug, block_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks"))
|
||||
@@ -0,0 +1,13 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/<int:block_id>/edit", methods=["POST"])
|
||||
def flow_block_edit(slug, screen_id, block_id):
|
||||
name = (request.form.get("name") or "").strip()
|
||||
description = request.form.get("description") or ""
|
||||
screens.update_flow_block(slug, block_id, name, description)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks"))
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/new", methods=["POST"])
|
||||
def flow_block_new(slug, screen_id):
|
||||
name = (request.form.get("name") or "").strip()
|
||||
description = request.form.get("description") or ""
|
||||
if name:
|
||||
screens.create_flow_block(slug, screen_id, name, description)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks"))
|
||||
@@ -38,6 +38,11 @@ def screen_edit(slug, screen_id):
|
||||
definitions = db.list_definitions(slug)
|
||||
definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
|
||||
|
||||
# list_flow_blocks() rattache paresseusement les nœuds "orphelins"
|
||||
# (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" —
|
||||
# DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes
|
||||
# garderait en mémoire un block_id périmé (None) pour ces nœuds.
|
||||
flow_blocks = screens.list_flow_blocks(slug, screen_id)
|
||||
flow_nodes = screens.list_flow_nodes(slug, screen_id)
|
||||
flow_edges = screens.list_flow_edges(slug, screen_id)
|
||||
elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements}
|
||||
@@ -48,6 +53,8 @@ def screen_edit(slug, screen_id):
|
||||
custom_event_usages = screens.list_custom_event_usages(slug)
|
||||
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
|
||||
|
||||
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels)
|
||||
|
||||
return render_template(
|
||||
"screen_edit.html",
|
||||
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
|
||||
@@ -85,4 +92,6 @@ def screen_edit(slug, screen_id):
|
||||
global_variables=global_variables,
|
||||
custom_events=custom_events, custom_event_usages=custom_event_usages,
|
||||
custom_events_map_json=json.dumps(custom_events_map),
|
||||
blocks_view=blocks_view,
|
||||
flow_blocks_json=json.dumps(flow_blocks),
|
||||
)
|
||||
|
||||
@@ -108,6 +108,13 @@ from .flow.delete_flow_node import delete_flow_node
|
||||
from .flow.add_flow_edge import add_flow_edge
|
||||
from .flow.delete_flow_edge import delete_flow_edge
|
||||
|
||||
from .flow.blocks.list_flow_blocks import list_flow_blocks
|
||||
from .flow.blocks.get_flow_block import get_flow_block
|
||||
from .flow.blocks.create_flow_block import create_flow_block
|
||||
from .flow.blocks.update_flow_block import update_flow_block
|
||||
from .flow.blocks.delete_flow_block import delete_flow_block
|
||||
from .flow.blocks.blocks_view import blocks_view
|
||||
|
||||
from .data_actions.apply_data_action import apply_data_action
|
||||
from .data_actions.apply_variable_action import apply_variable_action
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import db
|
||||
|
||||
from .compute_operation import compute_new_value
|
||||
|
||||
|
||||
def apply_data_action(slug, action):
|
||||
"""Exécute au moment du clic (mode jouable) une action "modifier_donnee" :
|
||||
@@ -28,29 +30,9 @@ def apply_data_action(slug, action):
|
||||
col = col + "_id"
|
||||
current = row.get(col)
|
||||
|
||||
if operation == "definir_texte":
|
||||
new_value = raw_value or ""
|
||||
elif operation == "definir_nombre":
|
||||
try:
|
||||
new_value = float(raw_value) if field_def["type"] == "nombre_decimal" else int(float(raw_value))
|
||||
except (TypeError, ValueError):
|
||||
new_value = 0
|
||||
elif operation == "definir_bool_vrai":
|
||||
new_value = 1
|
||||
elif operation == "definir_bool_faux":
|
||||
new_value = 0
|
||||
elif operation == "basculer_bool":
|
||||
new_value = 0 if current else 1
|
||||
elif operation in ("incrementer", "decrementer"):
|
||||
try:
|
||||
amount = float(raw_value) if raw_value not in (None, "") else 0
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
if operation == "decrementer":
|
||||
amount = -amount
|
||||
result = (current or 0) + amount
|
||||
new_value = result if field_def["type"] == "nombre_decimal" else int(result)
|
||||
else:
|
||||
new_value = compute_new_value(operation, current, raw_value, field_def["type"] == "nombre_decimal")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
new_value = _clamp_to_field_bounds(new_value, field_def)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import db
|
||||
|
||||
from .compute_operation import compute_new_value
|
||||
|
||||
|
||||
def apply_variable_action(slug, action):
|
||||
"""Exécute au moment du clic (mode jouable) une action
|
||||
@@ -25,29 +27,9 @@ def apply_variable_action(slug, action):
|
||||
elif var_type == "booleen":
|
||||
current = 1 if str(current).strip().lower() in ("1", "true", "vrai") else 0
|
||||
|
||||
if operation == "definir_texte":
|
||||
new_value = raw_value or ""
|
||||
elif operation == "definir_nombre":
|
||||
try:
|
||||
new_value = float(raw_value) if var_type == "nombre_decimal" else int(float(raw_value))
|
||||
except (TypeError, ValueError):
|
||||
new_value = 0
|
||||
elif operation == "definir_bool_vrai":
|
||||
new_value = 1
|
||||
elif operation == "definir_bool_faux":
|
||||
new_value = 0
|
||||
elif operation == "basculer_bool":
|
||||
new_value = 0 if current else 1
|
||||
elif operation in ("incrementer", "decrementer"):
|
||||
try:
|
||||
amount = float(raw_value) if raw_value not in (None, "") else 0
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
if operation == "decrementer":
|
||||
amount = -amount
|
||||
result = (current or 0) + amount
|
||||
new_value = result if var_type == "nombre_decimal" else int(result)
|
||||
else:
|
||||
new_value = compute_new_value(operation, current, raw_value, var_type == "nombre_decimal")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return db.update_global_variable_value(slug, var_name, new_value)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
def compute_new_value(operation, current, raw_value, is_decimal):
|
||||
"""Calcule la nouvelle valeur d'un champ d'objet ou d'une variable
|
||||
globale pour une opération de DATA_OPERATIONS (voir
|
||||
screens/labels/data_operations.py) — logique partagée par
|
||||
apply_data_action.py (champ d'objet) et apply_variable_action.py
|
||||
(variable globale), qui ne diffèrent que par l'origine de `current`
|
||||
et la façon dont le résultat est persisté. `is_decimal` : True pour
|
||||
un champ/une variable de type "nombre_decimal" (résultat numérique
|
||||
gardé en flottant), False sinon (résultat toujours entier).
|
||||
|
||||
Lève ValueError si `operation` n'est pas reconnue — à l'appelant de
|
||||
la traduire en son propre échec (ex. `return False`)."""
|
||||
if operation == "definir_texte":
|
||||
return raw_value or ""
|
||||
if operation == "definir_nombre":
|
||||
try:
|
||||
return float(raw_value) if is_decimal else int(float(raw_value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if operation == "definir_bool_vrai":
|
||||
return 1
|
||||
if operation == "definir_bool_faux":
|
||||
return 0
|
||||
if operation == "basculer_bool":
|
||||
return 0 if current else 1
|
||||
if operation in ("incrementer", "decrementer"):
|
||||
try:
|
||||
amount = float(raw_value) if raw_value not in (None, "") else 0
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
if operation == "decrementer":
|
||||
amount = -amount
|
||||
result = (current or 0) + amount
|
||||
return result if is_decimal else int(result)
|
||||
raise ValueError(f"opération inconnue : {operation!r}")
|
||||
@@ -0,0 +1,20 @@
|
||||
def blocks_view(flow_blocks, flow_nodes, elements_labels):
|
||||
"""Enrichit chaque bloc (nom/description bruts) avec son nombre de
|
||||
nœuds et les éléments qu'il concerne (trigger_element_id des
|
||||
déclencheurs + target_element_id des actions, dédoublonnés) — calculé
|
||||
en Python à partir de ce que routes/screens/screen_edit.py a déjà
|
||||
chargé, sans requête SQL supplémentaire."""
|
||||
nodes_by_block = {}
|
||||
for n in flow_nodes:
|
||||
nodes_by_block.setdefault(n.get("block_id"), []).append(n)
|
||||
view = []
|
||||
for b in flow_blocks:
|
||||
block_nodes = nodes_by_block.get(b["id"], [])
|
||||
element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")}
|
||||
element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")}
|
||||
view.append({
|
||||
**b,
|
||||
"node_count": len(block_nodes),
|
||||
"element_labels": [elements_labels[eid] for eid in element_ids if eid in elements_labels],
|
||||
})
|
||||
return view
|
||||
@@ -0,0 +1,19 @@
|
||||
import db
|
||||
|
||||
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
|
||||
|
||||
|
||||
def create_flow_block(slug, screen_id, name, description=""):
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
ensure_flow_blocks_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)",
|
||||
(screen_id, name, (description or "").strip()),
|
||||
)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
@@ -0,0 +1,26 @@
|
||||
import db
|
||||
|
||||
from ..ensure_flow_schema import ensure_flow_schema
|
||||
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
|
||||
|
||||
|
||||
def delete_flow_block(slug, block_id):
|
||||
"""Supprime le bloc ET tout ce qu'il contient (nœuds + arêtes) — décision
|
||||
explicite de l'utilisateur, un bloc de logique se comporte comme une
|
||||
fonction qu'on supprime avec son corps. Patron identique à
|
||||
screens/custom_events/delete_custom_event.py, mais scopé à un seul bloc
|
||||
(pas game-wide) : les AUTRES blocs du même écran ne sont pas touchés."""
|
||||
ensure_flow_schema(slug)
|
||||
ensure_flow_blocks_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"""DELETE FROM _flow_edges WHERE from_node_id IN
|
||||
(SELECT id FROM _flow_nodes WHERE block_id = ?)
|
||||
OR to_node_id IN
|
||||
(SELECT id FROM _flow_nodes WHERE block_id = ?)""",
|
||||
(block_id, block_id),
|
||||
)
|
||||
conn.execute("DELETE FROM _flow_nodes WHERE block_id = ?", (block_id,))
|
||||
conn.execute("DELETE FROM _flow_blocks WHERE id = ?", (block_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,24 @@
|
||||
import db
|
||||
|
||||
|
||||
def ensure_flow_blocks_schema(slug):
|
||||
"""Un bloc de logique regroupe les nœuds d'UN écran (scène ou modèle)
|
||||
en sous-graphe nommé/décrit, pour rester lisible même quand un écran
|
||||
accumule des milliers de nœuds — voir _flow_nodes.block_id
|
||||
(screens/flow/ensure_flow_schema.py). Contrairement à un événement
|
||||
personnalisé (game-wide, voir db/custom_events/), un bloc n'a de sens
|
||||
que pour SON écran : "name" n'est donc PAS unique globalement."""
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS _flow_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,13 @@
|
||||
import db
|
||||
|
||||
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
|
||||
|
||||
|
||||
def get_flow_block(slug, block_id):
|
||||
if not block_id:
|
||||
return None
|
||||
ensure_flow_blocks_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT * FROM _flow_blocks WHERE id = ?", (block_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,45 @@
|
||||
import db
|
||||
|
||||
from ..ensure_flow_schema import ensure_flow_schema
|
||||
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
|
||||
|
||||
DEFAULT_BLOCK_NAME = "Bloc principal"
|
||||
|
||||
|
||||
def list_flow_blocks(slug, screen_id):
|
||||
"""Migration douce : un écran qui a des nœuds posés AVANT l'existence
|
||||
des blocs (block_id NULL) se voit attribuer, à la première consultation
|
||||
de l'onglet Blocs, un bloc "Bloc principal" auto-créé qui les regroupe —
|
||||
aucun script de migration séparé à faire tourner, aucune donnée perdue.
|
||||
Idempotent : si "Bloc principal" existe déjà pour cet écran, on le
|
||||
réutilise plutôt que d'en recréer un second."""
|
||||
ensure_flow_schema(slug)
|
||||
ensure_flow_blocks_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
orphan_count = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM _flow_nodes WHERE screen_id = ? AND block_id IS NULL",
|
||||
(screen_id,),
|
||||
).fetchone()["n"]
|
||||
if orphan_count:
|
||||
default_block = conn.execute(
|
||||
"SELECT id FROM _flow_blocks WHERE screen_id = ? AND name = ?",
|
||||
(screen_id, DEFAULT_BLOCK_NAME),
|
||||
).fetchone()
|
||||
if default_block:
|
||||
default_block_id = default_block["id"]
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)",
|
||||
(screen_id, DEFAULT_BLOCK_NAME, ""),
|
||||
)
|
||||
default_block_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.execute(
|
||||
"UPDATE _flow_nodes SET block_id = ? WHERE screen_id = ? AND block_id IS NULL",
|
||||
(default_block_id, screen_id),
|
||||
)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM _flow_blocks WHERE screen_id = ? ORDER BY id", (screen_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,17 @@
|
||||
import db
|
||||
|
||||
from .ensure_flow_blocks_schema import ensure_flow_blocks_schema
|
||||
|
||||
|
||||
def update_flow_block(slug, block_id, name, description=""):
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return
|
||||
ensure_flow_blocks_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _flow_blocks SET name = ?, description = ? WHERE id = ?",
|
||||
(name, (description or "").strip(), block_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -86,4 +86,9 @@ FLOW_NODE_FIELDS = {
|
||||
# avec ses propres réglages habituels (target_element_id normal, pas
|
||||
# de mécanisme dynamique dédié).
|
||||
"trigger_custom_event_id", "target_custom_event_id",
|
||||
# Blocs de logique (voir screens/flow/blocks/) : à quel bloc ce nœud
|
||||
# appartient. Aucun `update_flow_node` n'existe (éditer un nœud le
|
||||
# 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",
|
||||
}
|
||||
|
||||
@@ -83,5 +83,18 @@ def ensure_flow_schema(slug):
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER")
|
||||
if "target_custom_event_id" not in cols:
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER")
|
||||
if "block_id" not in cols:
|
||||
# Blocs de logique (voir screens/flow/blocks/) : regroupe les nœuds
|
||||
# d'un même écran en sous-graphes nommés/décrits pour rester lisible
|
||||
# à l'échelle de milliers de nœuds. Pas de FK vers _flow_blocks —
|
||||
# même rationale que trigger_element_id/target_element_id
|
||||
# (screens/elements/delete_element.py) : la suppression d'un bloc
|
||||
# nettoie déjà elle-même ces lignes (voir
|
||||
# screens/flow/blocks/delete_flow_block.py), une contrainte ferait
|
||||
# double emploi. NULL = nœud pas encore rattaché à un bloc (jeux
|
||||
# créés avant cette fonctionnalité) — list_flow_blocks() les
|
||||
# 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")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Tests de la logique PURE du moteur (aucun accès DOM) — voir le plan de
|
||||
// modularisation JS. Lancé via `node --test static/js/play/__tests__/`.
|
||||
// Ces fonctions lisent des globals `gameData`/`window` (posés par
|
||||
// play.html en navigateur) — on les simule ici avant chaque test.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
const {
|
||||
compareValues,
|
||||
resolveVariablePath,
|
||||
readFieldValue,
|
||||
readVariableValue,
|
||||
evaluateConditionClause,
|
||||
evaluateConditionNode,
|
||||
} = require('../conditions.js');
|
||||
|
||||
function withGameData(data, fn) {
|
||||
const previous = global.gameData;
|
||||
global.gameData = data;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
global.gameData = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test('compareValues — comparaison numérique', () => {
|
||||
assert.equal(compareValues('10', 'superieur', '5'), true);
|
||||
assert.equal(compareValues('3', 'inferieur', '5'), true);
|
||||
assert.equal(compareValues('5', 'egal', '5'), true);
|
||||
assert.equal(compareValues('5', 'different', '5'), false);
|
||||
});
|
||||
|
||||
test('compareValues — repli sur comparaison de chaînes si non numérique', () => {
|
||||
assert.equal(compareValues('abc', 'egal', 'abc'), true);
|
||||
assert.equal(compareValues('abc', 'different', 'xyz'), true);
|
||||
});
|
||||
|
||||
test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => {
|
||||
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
|
||||
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
|
||||
assert.equal(compareValues(true, 'different', 'non', 'booleen'), true);
|
||||
});
|
||||
|
||||
test('resolveVariablePath — chemin vide renvoie la valeur brute', () => {
|
||||
assert.equal(resolveVariablePath('42', ''), '42');
|
||||
});
|
||||
|
||||
test('resolveVariablePath — chemin .champ / [index] chaînable', () => {
|
||||
const raw = JSON.stringify({ inventaire: [{ nom: 'clé' }, { nom: 'épée' }] });
|
||||
assert.equal(resolveVariablePath(raw, '.inventaire[1].nom'), 'épée');
|
||||
});
|
||||
|
||||
test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () => {
|
||||
assert.equal(resolveVariablePath('{pas du json', '.x'), null);
|
||||
assert.equal(resolveVariablePath('{"a":1}', '.b.c'), null);
|
||||
});
|
||||
|
||||
test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => {
|
||||
withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => {
|
||||
window.lastClickedRowId = 7;
|
||||
assert.equal(readFieldValue(1, -1, 'score'), 42);
|
||||
});
|
||||
});
|
||||
|
||||
test('readVariableValue — lit gameData.variables par nom', () => {
|
||||
withGameData({ variables: { points: { type: 'nombre_entier', value: '10' } } }, () => {
|
||||
assert.equal(readVariableValue('points'), '10');
|
||||
assert.equal(readVariableValue('inconnue'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "objet" (comportement historique)', () => {
|
||||
withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => {
|
||||
const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "variable"', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' };
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => {
|
||||
const node = {
|
||||
cond_combinator: 'et',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '3' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => {
|
||||
const node = {
|
||||
cond_combinator: 'ou',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '999' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
// ---------- Exécution des actions ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation) :
|
||||
// application d'une propriété d'élément ("Modifier un élément") et
|
||||
// exécution de chaque type de nœud Action. Les URLs de routes serveur
|
||||
// (flow_node_run_data/run_variable) sont injectées par play.html dans
|
||||
// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici).
|
||||
|
||||
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
|
||||
// pilotable par une action "Modifier un élément".
|
||||
var ELEMENT_PROPERTY_CSS = {
|
||||
bg_color: { css: 'backgroundColor', suffix: '' },
|
||||
border_color: { css: 'borderColor', suffix: '' },
|
||||
width: { css: 'width', suffix: '%' },
|
||||
height: { css: 'height', suffix: '%' },
|
||||
};
|
||||
|
||||
function applyElementProperty(targetEl, property, value) {
|
||||
if (property === 'contenu') {
|
||||
// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de
|
||||
// binding vers un champ d'objet ici (contrairement à
|
||||
// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme
|
||||
// pour les autres réglages ci-dessous, cible n'importe quel élément —
|
||||
// pointer ceci vers un conteneur remplacerait tout son contenu
|
||||
// imbriqué, à éviter.
|
||||
targetEl.textContent = value == null ? '' : value;
|
||||
return;
|
||||
}
|
||||
if (property === 'surbrillance') {
|
||||
// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
|
||||
// clignote (voir la classe .forgeHighlight et son animation dans le
|
||||
// <style> de play.html), applicable à N'IMPORTE QUEL élément sans
|
||||
// poser de bordure colorée togglée à la main — utile pour un mentor
|
||||
// qui attire l'attention du joueur vers l'élément à toucher ensuite.
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeHighlight');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeHighlight');
|
||||
} else {
|
||||
targetEl.classList.add('forgeHighlight');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property === 'desactive') {
|
||||
// 3.5 (Confort) — verrouillage d'un élément après décision : DISTINCT
|
||||
// de "invisible" (l'élément reste visible, mais grisé et inerte —
|
||||
// pointer-events:none bloque aussi le clic, donc le déclencheur "Au
|
||||
// clic" éventuellement posé sur cet élément ne se redéclenche plus).
|
||||
if (value === 'toggle') {
|
||||
targetEl.classList.toggle('forgeDisabled');
|
||||
} else if (value === 'off') {
|
||||
targetEl.classList.remove('forgeDisabled');
|
||||
} else {
|
||||
targetEl.classList.add('forgeDisabled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property === 'visibilite') {
|
||||
if (value === 'toggle') {
|
||||
const hidden = targetEl.style.display === 'none';
|
||||
targetEl.style.display = hidden ? '' : 'none';
|
||||
targetEl.style.visibility = '';
|
||||
} else if (value === 'masque') {
|
||||
targetEl.style.display = 'none';
|
||||
} else if (value === 'invisible') {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = 'hidden';
|
||||
} else {
|
||||
targetEl.style.display = '';
|
||||
targetEl.style.visibility = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = ELEMENT_PROPERTY_CSS[property];
|
||||
if (!spec || value === undefined || value === null) return;
|
||||
|
||||
// "valeur1||valeur2" : effet va-et-vient — un état par élément et par
|
||||
// propriété, mémorisé directement sur le nœud DOM (data-toggle-<prop>) :
|
||||
// un clic passe à la 2e valeur, le clic suivant revient à la 1re, et
|
||||
// ainsi de suite. Ce n'est PAS enregistré en base : l'état repart de la
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = 'toggle' + property;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
targetEl.dataset[key] = atSecond ? '0' : '1';
|
||||
return;
|
||||
}
|
||||
|
||||
targetEl.style[spec.css] = value + spec.suffix;
|
||||
}
|
||||
|
||||
function runActionNode(node) {
|
||||
if (node.action_type === 'ecran_suivant') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||||
// précise.
|
||||
if (window.lastClickedRowId != null && window.lastClickedDefinitionId != null) {
|
||||
window.currentOpenRow = { definitionId: window.lastClickedDefinitionId, rowId: window.lastClickedRowId };
|
||||
}
|
||||
showScreen(node.target_screen_id);
|
||||
applyOpenRowBindings();
|
||||
// Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI
|
||||
// un Répéteur — ex. un panneau de détail posé à côté d'une liste dans
|
||||
// le même conteneur — applyOpenRowBindings() vient de régénérer son
|
||||
// sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs
|
||||
// SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti-
|
||||
// doublon repose sur elDiv.dataset.clickBound, absent sur un nœud
|
||||
// fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne
|
||||
// cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les
|
||||
// lignes muettes dès le clic suivant, sans la moindre erreur.
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||
// signaler l'événement, jamais choisir un élément — c'est à
|
||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||||
// l'écran) en une seule action, plutôt qu'une action "Masquer" par
|
||||
// frère. screenData.elements contient TOUS les éléments de l'écran
|
||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter(function(e){ return (e.parent_id || null) === parentKey; })
|
||||
.forEach(function(e) {
|
||||
const el = document.querySelector('[data-element-id="' + e.id + '"]');
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
|
||||
else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'attendre') {
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du
|
||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
const seconds = parseFloat(node.data_value);
|
||||
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
} else if (node.action_type === 'modifier_donnee') {
|
||||
const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id);
|
||||
// clicked_row_id : utilisé côté serveur SEULEMENT si ce nœud vise la
|
||||
// ligne cliquée (CLICKED_ROW_ID, voir flow_node_run_data.py) — sans
|
||||
// effet sinon, donc toujours transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'modifier_variable') {
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
}
|
||||
// 'rien' : aucune action volontairement.
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// ---------- Données liées : résolution des placeholders, rafraîchissement ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation).
|
||||
|
||||
// Résout, sur l'écran couramment affiché, les placeholders "champ entre
|
||||
// doubles accolades" restés tels quels dans le HTML statique (hors
|
||||
// Répéteur, donc jamais résolus au moment du rendu serveur) avec les
|
||||
// valeurs de la DERNIÈRE ligne ouverte via l'action "Ouvrir la ligne
|
||||
// cliquée" (window.currentOpenRow). On part à chaque fois du HTML
|
||||
// D'ORIGINE (el.rendered_html, jamais modifié) et pas du DOM déjà
|
||||
// affiché, sinon rouvrir un mail différent laisserait les valeurs du mail
|
||||
// précédent là où le nouveau mail n'a pas ce champ.
|
||||
//
|
||||
// Note : on construit les accolades doubles caractère par caractère
|
||||
// (PLACEHOLDER_OPEN/CLOSE) plutôt que de les écrire en dur — {{ et }}
|
||||
// adjacents dans templates/play.html seraient interprétés par Jinja (le
|
||||
// moteur de templates serveur) comme DU JINJA ; ici, dans un fichier
|
||||
// statique jamais passé par Jinja, ce n'est plus nécessaire, mais la
|
||||
// construction est conservée pour rester visuellement cohérente avec le
|
||||
// reste du moteur et éviter toute confusion en relisant le code.
|
||||
var PLACEHOLDER_OPEN = '{' + '{';
|
||||
var PLACEHOLDER_CLOSE = '}' + '}';
|
||||
|
||||
function applyOpenRowBindings() {
|
||||
if (!window.currentOpenRow) return;
|
||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||
const row = rows.find(r => r.id === window.currentOpenRow.rowId);
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
if (!row || !screenData || !screenDiv) return;
|
||||
|
||||
// Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les
|
||||
// éléments qui portent vraiment un {{champ}} non résolu — et non plus
|
||||
// seulement les éléments de PREMIER NIVEAU de l'écran. Avant ce
|
||||
// correctif, un panneau de détail niché à 2-3 niveaux (ex. "mail
|
||||
// content" posé dans un conteneur, lui-même dans un autre) forçait à
|
||||
// regénérer TOUT son ancêtre de premier niveau — potentiellement
|
||||
// l'écran entier (jauges, onglets, Répéteur compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine — d'où l'impression trompeuse d'un
|
||||
// rechargement de page (flash à vide) à chaque clic sur une ligne.
|
||||
//
|
||||
// Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que
|
||||
// les plus SPÉCIFIQUES (les plus profonds) : si un élément ET l'un de
|
||||
// ses DESCENDANTS sont tous deux candidats, seul le descendant est
|
||||
// régénéré (rendered_html d'un conteneur inclut TOUJOURS celui de ses
|
||||
// descendants, donc un conteneur "candidat" ne l'est souvent que parce
|
||||
// qu'un descendant à lui l'est — le garder en plus regénérerait
|
||||
// inutilement tout son sous-arbre, à l'exact opposé du but recherché).
|
||||
// ATTENTION : la version précédente de ce filtre vérifiait l'inverse
|
||||
// (exclure un élément si un ANCÊTRE est candidat) — comme un ancêtre
|
||||
// est presque toujours "candidat" dès qu'un descendant l'est (son HTML
|
||||
// le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine
|
||||
// de l'écran, annulant complètement l'effet recherché par ce correctif.
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
function hasUnresolvedPlaceholder(e) {
|
||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||
const targets = candidates.filter(function(e) {
|
||||
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
});
|
||||
|
||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
|
||||
// mais posé à la main puisqu'il n'y a pas de vrai DOM virtuel ici :
|
||||
// - dans un nœud TEXTE : {{champ}} est coupé en 3 (texte avant, un
|
||||
// <span data-bind-field="champ"> qui portera la valeur, texte
|
||||
// après) LA PREMIÈRE FOIS SEULEMENT ; les ouvertures suivantes se
|
||||
// contentent de changer le textContent de ce span.
|
||||
// - dans un ATTRIBUT (ex. href d'un lien "{{link_real_url}}") : le
|
||||
// gabarit d'origine est mémorisé sur un data-bind-attr-<nom> lors du
|
||||
// premier passage, pour pouvoir recalculer et réécrire l'attribut à
|
||||
// chaque fois sans jamais avoir besoin de reconstruire le nœud.
|
||||
// Avant ce correctif, tout le sous-arbre ciblé était détruit et recréé
|
||||
// depuis zéro à chaque clic sur une ligne — ce qui causait un flash
|
||||
// visuel (perte de tout état transitoire posé par "Modifier un
|
||||
// élément", donnant l'impression trompeuse d'un rechargement de page).
|
||||
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
|
||||
// rien à sauvegarder ni à restaurer.
|
||||
const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g');
|
||||
|
||||
function resolveTextNodes(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const toSplit = [];
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
||||
}
|
||||
toSplit.forEach(function(textNode) {
|
||||
const raw = textNode.nodeValue;
|
||||
placeholderRe.lastIndex = 0;
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIndex = 0, match;
|
||||
while ((match = placeholderRe.exec(raw))) {
|
||||
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
|
||||
const span = document.createElement('span');
|
||||
span.dataset.bindField = match[1];
|
||||
span.textContent = row[match[1]] == null ? '' : String(row[match[1]]);
|
||||
frag.appendChild(span);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex)));
|
||||
textNode.replaceWith(frag);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTextSlots(root) {
|
||||
root.querySelectorAll('[data-bind-field]').forEach(function(slot) {
|
||||
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
|
||||
if (slot.textContent !== value) slot.textContent = value;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAttributes(root) {
|
||||
[root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) {
|
||||
Array.from(node.attributes).forEach(function(attr) {
|
||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
||||
const bindKey = 'data-bind-attr-' + attr.name;
|
||||
const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
if (template === null) return;
|
||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||
placeholderRe.lastIndex = 0;
|
||||
const resolved = template.replace(placeholderRe, function(_, fieldName) {
|
||||
return row[fieldName] == null ? '' : String(row[fieldName]);
|
||||
});
|
||||
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
targets.forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
if (!target) return;
|
||||
if (target.querySelector('[data-bind-field]')) {
|
||||
updateTextSlots(target);
|
||||
} else {
|
||||
resolveTextNodes(target);
|
||||
}
|
||||
resolveAttributes(target);
|
||||
});
|
||||
}
|
||||
|
||||
// Recharge l'état du jeu depuis le serveur (après une action qui a
|
||||
// modifié une donnée d'objet) pour refléter la nouvelle valeur — utile
|
||||
// pour un Répéteur de données, qui doit réafficher ses lignes à jour.
|
||||
// Important : on ne touche QU'AUX éléments qui contiennent réellement un
|
||||
// répéteur (repérable par la classe "repeaterItem" dans leur HTML rendu).
|
||||
// Reconstruire TOUT l'écran à chaque rafraîchissement effacerait les
|
||||
// changements purement visuels faits en même temps par une action
|
||||
// "Modifier un élément" (ex: masquer une boîte) déclenchée par le même
|
||||
// clic, puisque ces changements ne sont jamais enregistrés côté serveur.
|
||||
function refreshRuntimeData() {
|
||||
return fetch(FORGE_PLAY_URLS.runtimePayload)
|
||||
.then(r => r.json())
|
||||
.then(newData => {
|
||||
gameData = newData;
|
||||
screensData = gameData.screens;
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
||||
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
||||
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
||||
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
||||
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
||||
// de clic pour le reste de la partie (plus aucune ligne ne
|
||||
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
||||
// risque à rappeler même quand rien n'a changé.
|
||||
if (screenData && screenDiv) {
|
||||
// try/catch : une erreur inattendue pendant la régénération (ex.
|
||||
// structure de données imprévue) ne doit jamais empêcher les
|
||||
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
||||
// tout autre élément cliquable) reste silencieusement inerte pour
|
||||
// le reste de la partie, sans le moindre message d'erreur visible.
|
||||
try {
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée), "visibilityGated" (condition de visibilité) et
|
||||
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
|
||||
// render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
//
|
||||
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
||||
// (pas seulement le premier niveau de l'écran), via leur
|
||||
// data-element-id — disponible sur chaque élément rendu, pas
|
||||
// seulement les éléments de premier niveau. Avant ce correctif,
|
||||
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
||||
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
||||
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
||||
// et se retrouvait régénéré en entier à sa place — potentiellement
|
||||
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine.
|
||||
//
|
||||
// Parmi les éléments marqués, on ne garde que les plus
|
||||
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
||||
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
||||
// son rendered_html contient celui de ses descendants — le
|
||||
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
||||
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
||||
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
||||
// gardait en pratique que l'ancêtre racine de l'écran et
|
||||
// annulait complètement l'effet recherché.)
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
function hasMarker(e) {
|
||||
return !!(e.rendered_html && (
|
||||
e.rendered_html.indexOf('repeaterItem') !== -1 ||
|
||||
e.rendered_html.indexOf('jaugeBar') !== -1 ||
|
||||
e.rendered_html.indexOf('visibilityGated') !== -1 ||
|
||||
e.rendered_html.indexOf('dataBound') !== -1
|
||||
));
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||
// HTML juste après la balise concernée (voir _mark() dans
|
||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
||||
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
|
||||
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
|
||||
// comportement déjà correct pour ces deux-là). Repérer ce
|
||||
// commentaire permet de retrouver, à l'intérieur d'un fragment
|
||||
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
|
||||
// est niché dans un exemplaire d'élément de jeu réutilisable
|
||||
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
|
||||
// dialogue), où lui seul porte l'attribut data-element-id qui
|
||||
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||
// de premier niveau existant (l'exemplaire) que parce que son
|
||||
// HTML est imbriqué dans le sien.
|
||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
||||
function findCommentMarkedDescendants(root) {
|
||||
const found = [];
|
||||
root.querySelectorAll('[data-element-id]').forEach(function(node) {
|
||||
const sib = node.nextSibling;
|
||||
const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
// commentaire marqueur à sa suite — sa balise porte
|
||||
// directement la classe "visibilityGated" à la place. Sans
|
||||
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
|
||||
// n'était jamais retrouvé ici (seul le sens caché->visible
|
||||
// l'était, via le commentaire) : son ancien nœud visible
|
||||
// restait donc affiché indéfiniment à côté du nouveau,
|
||||
// maintenant visible, qui le remplace normalement (ex. une
|
||||
// icône "enveloppe fermée" jamais masquée en même temps que
|
||||
// "enveloppe ouverte" qui apparaît après avoir cliqué).
|
||||
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
|
||||
if (hasCommentMarker || isHiddenPlaceholder) {
|
||||
found.push(node);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates.filter(function(e) {
|
||||
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
}).forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
if (!target) return;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = el.rendered_html;
|
||||
const newNode = container.firstElementChild;
|
||||
if (!newNode) return;
|
||||
const innerMarked = findCommentMarkedDescendants(newNode);
|
||||
if (innerMarked.length) {
|
||||
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
|
||||
// Texte "Donnée liée" posé dans un élément de jeu
|
||||
// réutilisable) : on ne patche QUE ceux-là, en préservant
|
||||
// tout le reste — une boîte de dialogue ne doit ni
|
||||
// disparaître ni réapparaître juste parce qu'un texte à
|
||||
// l'intérieur a changé.
|
||||
innerMarked.forEach(function(innerNode) {
|
||||
const innerId = innerNode.getAttribute('data-element-id');
|
||||
const liveInner = target.querySelector('[data-element-id="' + innerId + '"]');
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
} else {
|
||||
target.replaceWith(newNode);
|
||||
}
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
} catch (err) {
|
||||
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
||||
}
|
||||
}
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
||||
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
||||
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||
// tout de suite, sans attendre un aller-retour d'écran.
|
||||
runScreenShowTriggers(window.currentScreenId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// ---------- Évaluation des conditions ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation) : partie
|
||||
// PURE du moteur (aucun accès DOM) — lecture de champs/variables et
|
||||
// comparaison, c'est la logique la plus amenée à grossir (nouvelles
|
||||
// opérations, condition de collision...), donc la plus utile à tester
|
||||
// (voir static/js/play/__tests__/conditions.test.js).
|
||||
|
||||
// Lit la valeur actuelle d'un champ d'objet dans l'instantané de données
|
||||
// du jeu (gameData.data), pour l'évaluation d'une condition.
|
||||
function readFieldValue(definitionId, rowId, fieldName) {
|
||||
// CLICKED_ROW_ID (-1, voir flow_node_run_data.py) : "la ligne de
|
||||
// Répéteur sur laquelle on vient de cliqué" — jamais connue à l'avance
|
||||
// dans l'éditeur (choisie ici via "🖱️ Ligne cliquée (Répéteur)"),
|
||||
// résolue seulement au moment de l'évaluation via le dernier clic
|
||||
// capturé (voir bindClicks() dans triggers.js).
|
||||
if (rowId === -1) rowId = window.lastClickedRowId;
|
||||
const rows = gameData.data[String(definitionId)] || [];
|
||||
const row = rows.find(r => r.id === rowId);
|
||||
return row ? row[fieldName] : undefined;
|
||||
}
|
||||
|
||||
function compareValues(actual, operator, expected, fieldType) {
|
||||
if (fieldType === 'booleen') {
|
||||
const a = actual ? 1 : 0;
|
||||
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
|
||||
// partout ailleurs pour un champ booléen — une valeur de comparaison
|
||||
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
|
||||
// seulement "1"/"true" (et insensible à la casse, aligné avec
|
||||
// _compare() côté Python, voir filter_repeater_rows.py).
|
||||
const expectedStr = String(expected).trim().toLowerCase();
|
||||
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual), en = parseFloat(expected);
|
||||
const numeric = !isNaN(an) && !isNaN(en);
|
||||
if (numeric) {
|
||||
switch (operator) {
|
||||
case 'egal': return an === en;
|
||||
case 'different': return an !== en;
|
||||
case 'superieur': return an > en;
|
||||
case 'inferieur': return an < en;
|
||||
case 'superieur_egal': return an >= en;
|
||||
case 'inferieur_egal': return an <= en;
|
||||
}
|
||||
}
|
||||
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
||||
const es = (expected === undefined || expected === null) ? '' : String(expected);
|
||||
switch (operator) {
|
||||
case 'egal': return as === es;
|
||||
case 'different': return as !== es;
|
||||
case 'superieur': return as > es;
|
||||
case 'inferieur': return as < es;
|
||||
case 'superieur_egal': return as >= es;
|
||||
case 'inferieur_egal': return as <= es;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lit la valeur ACTUELLE d'une variable globale (gameData.variables,
|
||||
// exposé par full_game_payload.py, tenu à jour par refreshRuntimeData()
|
||||
// après toute action qui en modifie une), pour l'évaluation d'une
|
||||
// condition — équivalent, côté client, de _resolve_filter_value côté
|
||||
// serveur (screens/rendering/filter_repeater_rows.py), mais SANS la
|
||||
// syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais
|
||||
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||
// déroulant, voir screen_edit.html).
|
||||
function readVariableValue(name) {
|
||||
const v = (gameData.variables || {})[name];
|
||||
return v ? v.value : undefined;
|
||||
}
|
||||
|
||||
// Navigue dans une valeur JSON (variable de type "objet"/"tableau")
|
||||
// selon un chemin ".champ"/"[index]" chaînable — équivalent JS de
|
||||
// _resolve_variable_path (même fichier Python que ci-dessus). Chemin
|
||||
// vide -> valeur brute inchangée (le cas normal pour une variable
|
||||
// scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond
|
||||
// à rien -> null, comme côté serveur.
|
||||
function resolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
let current;
|
||||
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
|
||||
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
||||
}
|
||||
return current === undefined ? null : current;
|
||||
}
|
||||
|
||||
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
|
||||
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
|
||||
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
|
||||
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
|
||||
//
|
||||
// Chaque clause peut tester soit un champ d'objet (source absente/"objet",
|
||||
// comportement historique), soit une VARIABLE GLOBALE (source
|
||||
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
|
||||
// cond_variable_chemin).
|
||||
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 === 'variable') {
|
||||
const varName = clause.variable ?? clause.cond_variable;
|
||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||
const varInfo = (gameData.variables || {})[varName];
|
||||
const actual = resolveVariablePath(readVariableValue(varName), path);
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
|
||||
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
|
||||
}
|
||||
|
||||
function evaluateConditionNode(node) {
|
||||
if (node.cond_clauses) {
|
||||
let clauses;
|
||||
try { clauses = JSON.parse(node.cond_clauses); } catch (e) { clauses = null; }
|
||||
if (Array.isArray(clauses) && clauses.length) {
|
||||
const results = clauses.map(evaluateConditionClause);
|
||||
return node.cond_combinator === 'ou' ? results.some(Boolean) : results.every(Boolean);
|
||||
}
|
||||
}
|
||||
return evaluateConditionClause(node);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) importe ces fonctions via
|
||||
// require() — pas de risque en navigateur : `module` n'existe pas là-bas,
|
||||
// cette branche ne s'exécute jamais côté client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ---------- Moteur du graphe de logique ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation) :
|
||||
// parcours du graphe (déclencheur → condition → action) et déclenchement
|
||||
// d'un événement personnalisé.
|
||||
|
||||
// Exécute le graphe de logique de l'écran courant à partir d'un nœud
|
||||
// (typiquement un Déclencheur touché par le joueur). Un même point de
|
||||
// sortie peut être relié à PLUSIEURS nœuds suivants (plusieurs actions
|
||||
// pour un même déclencheur, ou plusieurs suites pour une même branche
|
||||
// Vrai/Faux) — tous les fils sortants d'un port sont donc suivis, pas
|
||||
// seulement le premier. Chaque nœud Action produit son effet puis
|
||||
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
|
||||
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
|
||||
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
|
||||
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNodess
|
||||
// dans triggers.js) quand le nœud de départ vient d'un déclencheur posé
|
||||
// dans l'éditeur d'un élément de jeu réutilisable, sur un de ses propres
|
||||
// enfants. Sans ce paramètre, un tel déclencheur était bien retrouvé
|
||||
// (findTriggerNodess cherche dans tous les écrans) mais son graphe
|
||||
// n'existait jamais dans l'écran réellement affiché : rien ne se
|
||||
// déclenchait, silencieusement.
|
||||
function runFlowFrom(nodeId, flowScreenId) {
|
||||
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
|
||||
if (!flow) return;
|
||||
const nodesById = {};
|
||||
flow.nodes.forEach(n => { nodesById[n.id] = n; });
|
||||
|
||||
function nextEdges(fromId, port) {
|
||||
return flow.edges.filter(e => e.from_node_id === fromId && e.from_port === port);
|
||||
}
|
||||
|
||||
function step(currentId) {
|
||||
const node = nodesById[currentId];
|
||||
if (!node) return;
|
||||
if (node.node_type === 'trigger') {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'condition') {
|
||||
const port = evaluateConditionNode(node) ? 'vrai' : 'faux';
|
||||
nextEdges(node.id, port).forEach(edge => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'action') {
|
||||
runActionNode(node).then(() => {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
});
|
||||
}
|
||||
}
|
||||
step(nodeId);
|
||||
}
|
||||
|
||||
// Déclenche un événement personnalisé (voir screens/custom_events/,
|
||||
// action "declencher_evenement") : une pure NOTIFICATION, sans aucun
|
||||
// paramètre — scan GLOBAL de gameData.flows, même principe que
|
||||
// findTriggerNodess()/runScreenShowTriggers() (triggers.js), puisqu'un
|
||||
// écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur
|
||||
// N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché.
|
||||
// C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
|
||||
// suite condition/action, avec ses réglages habituels — cible fixe,
|
||||
// "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
|
||||
window.dispatchGameEvent = function(eventId) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
const flow = gameData.flows[screenIdKey];
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
|
||||
.forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
// ---------- Affichage des écrans, timeline d'animation ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation — un
|
||||
// fichier = une responsabilité, aussi pour le JS) : navigation entre
|
||||
// écrans, proportion de la zone de jeu, lecture des clips d'animation
|
||||
// posés dans l'éditeur ("Timeline d'animation").
|
||||
|
||||
// Élément à onglets (voir render_onglets.py) : bascule le panneau visible
|
||||
// sans aller-retour serveur — même fonction que dans screen_edit.html,
|
||||
// pour un comportement identique éditeur/jeu.
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function showScreen(id) {
|
||||
// Déjà l'écran affiché : rien à refaire. Sans ce garde-fou, l'action
|
||||
// "Ouvrir la ligne cliquée" (voir plus bas) — pensée pour révéler un
|
||||
// détail SUR LE MÊME écran qu'un Répéteur, en la combinant avec une
|
||||
// action "Modifier un élément → Visibilité" sur le même clic — rejouait
|
||||
// à chaque clic sur une ligne les déclencheurs "À l'affichage de
|
||||
// l'écran" et relançait la timeline d'animation depuis le début, ce qui
|
||||
// donnait l'impression trompeuse d'un rechargement de page (éléments
|
||||
// qui rejouent leur animation d'entrée, visibilité qu'un déclencheur
|
||||
// "affichage" repasse à son état initial en écrasant l'action "Rendre
|
||||
// visible" du même clic...). Aller vers un écran DIFFÉRENT continue de
|
||||
// tout rejouer normalement, comme un vrai premier affichage.
|
||||
if (id === window.currentScreenId) return;
|
||||
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
|
||||
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
|
||||
if (el) {
|
||||
el.style.display = 'block';
|
||||
window.currentScreenId = id;
|
||||
applyScreenAspect(el.dataset.aspect || '9/16');
|
||||
}
|
||||
runScreenShowTriggers(id);
|
||||
runAnimationTimeline(id);
|
||||
}
|
||||
|
||||
// Voir le commentaire sur #playFrame dans le <style> de play.html : la
|
||||
// zone de jeu garde toujours la proportion de l'écran affiché, quelle
|
||||
// que soit la forme réelle de la fenêtre du joueur.
|
||||
function applyScreenAspect(aspect) {
|
||||
var parts = String(aspect).split('/');
|
||||
var w = parseFloat(parts[0]) || 9, h = parseFloat(parts[1]) || 16;
|
||||
var frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.style.setProperty('--ratio-w', w);
|
||||
frame.style.setProperty('--ratio-h', h);
|
||||
}
|
||||
}
|
||||
|
||||
function screenIndexById(id) {
|
||||
return screensData.findIndex(s => s.id === id);
|
||||
}
|
||||
|
||||
// ---------- Timeline d'animation (menu "Ajouter un élément" → panneau
|
||||
// rétractable sous la Logique de la scène) : chaque clip démarre à un
|
||||
// instant précis (start_time, en secondes) après l'affichage de l'écran —
|
||||
// exactement comme une timeline Unity jouée au chargement d'une scène.
|
||||
// Deux types de clip : "animate_css" pose une classe de la bibliothèque
|
||||
// Animate.css (voir animate_css_catalog.py), "custom" joue des
|
||||
// images-clés propres au jeu (voir injectCustomKeyframes ci-dessous). ----------
|
||||
var pendingAnimationTimers = [];
|
||||
|
||||
function injectCustomKeyframes() {
|
||||
// Construit UNE FOIS toutes les @keyframes "personnalisées" de tous les
|
||||
// écrans (les données sont déjà toutes chargées dans gameData) et les
|
||||
// pose dans une unique balise <style>, plutôt que d'en injecter une par
|
||||
// clip à chaque lecture — un nom de règle unique par clip (customAnim_id)
|
||||
// évite toute collision entre deux clips personnalisés différents.
|
||||
var rules = [];
|
||||
Object.keys(gameData.animations || {}).forEach(function(screenId) {
|
||||
(gameData.animations[screenId] || []).forEach(function(clip) {
|
||||
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
|
||||
var keyframes;
|
||||
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
|
||||
var steps = keyframes.map(function(k) {
|
||||
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
||||
return k.percent + '% { ' + props + ' }';
|
||||
}).join(' ');
|
||||
rules.push('@keyframes customAnim_' + clip.id + ' { ' + steps + ' }');
|
||||
});
|
||||
});
|
||||
var styleTag = document.getElementById('customAnimStyles');
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = 'customAnimStyles';
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
styleTag.textContent = rules.join('\n');
|
||||
}
|
||||
|
||||
function applyAnimationClip(clip) {
|
||||
// querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un
|
||||
// écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres
|
||||
// enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce
|
||||
// modèle (rechargé en direct depuis le même écran-modèle, voir
|
||||
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
|
||||
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
|
||||
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
|
||||
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
|
||||
if (!targets.length) return;
|
||||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||||
var iterationCount = clip.iteration_count || '1';
|
||||
var infinite = iterationCount === 'infinite';
|
||||
targets.forEach(function(target) {
|
||||
if (clip.kind === 'custom') {
|
||||
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
|
||||
' ' + delay + 's ' + iterationCount;
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.classList.remove('animate__animated', animClass);
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE
|
||||
// utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet
|
||||
// écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser
|
||||
// un autre modèle), avec une garde anti-boucle et un dédoublonnage par
|
||||
// écran (un même modèle posé plusieurs fois ne doit pas répéter ses
|
||||
// clips). Sans ça, une animation posée directement dans l'éditeur d'un
|
||||
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
|
||||
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
|
||||
function collectAnimationClips(screenId) {
|
||||
var visited = {};
|
||||
var clips = [];
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach(function(el) {
|
||||
if (!el.element_type_id) return;
|
||||
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
visit(screenId);
|
||||
return clips;
|
||||
}
|
||||
|
||||
function runAnimationTimeline(screenId) {
|
||||
// Repart de zéro à chaque affichage de cet écran (comme
|
||||
// runScreenShowTriggers) : on annule d'abord tout clip encore en
|
||||
// attente d'un affichage précédent, pour ne jamais empiler des
|
||||
// déclenchements en double si le joueur revient plusieurs fois de
|
||||
// suite sur le même écran avant la fin de sa timeline.
|
||||
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
|
||||
pendingAnimationTimers = [];
|
||||
var clips = collectAnimationClips(screenId);
|
||||
clips.forEach(function(clip) {
|
||||
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
pendingAnimationTimers.push(t);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// ---------- Déclencheurs : recherche des nœuds + attache des écouteurs ----------
|
||||
// Extrait de templates/play.html (voir plan de modularisation).
|
||||
|
||||
// Déclenche tous les nœuds "À l'affichage de l'écran" de CET écran — pas
|
||||
// besoin d'avoir cliqué sur quoi que ce soit : ça couvre le premier
|
||||
// affichage, un retour en arrière, un changement d'écran (ecran_suivant/
|
||||
// ecran_precedent/aller_a/ouvrir_ligne appellent tous showScreen). C'est
|
||||
// ce qui permet à une condition ("SI outil débloqué = faux") + une action
|
||||
// "Modifier un élément → Visibilité" de refléter l'état de la partie sans
|
||||
// attendre un clic (voir 1.2 dans la doc de cadrage Forge Engine).
|
||||
function runScreenShowTriggers(screenId) {
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'affichage'; })
|
||||
.forEach(function(n){ runFlowFrom(n.id); });
|
||||
}
|
||||
|
||||
// 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
|
||||
// nœud) : rien n'empêche de poser plusieurs déclencheurs indépendants
|
||||
// "Au clic" sur le MÊME élément (ex. l'un ouvre une ligne de Répéteur,
|
||||
// l'autre éteint une surbrillance) — un seul clic doit alors exécuter
|
||||
// CHACUN de ces graphes, pas seulement le premier trouvé (régression
|
||||
// vécue : un second déclencheur posé sur un élément qui en avait déjà un
|
||||
// restait silencieusement inerte, aucune erreur, juste jamais exécuté).
|
||||
//
|
||||
// On cherche dans TOUS les écrans (pas seulement l'écran courant) :
|
||||
// bindClicks() attache les gestionnaires une seule fois, sur tous les
|
||||
// éléments de tous les écrans (déjà tous présents dans le DOM, seuls les
|
||||
// écrans non actifs sont masqués) — chaque identifiant d'élément est
|
||||
// unique dans tout le jeu, donc pas d'ambiguïté possible. Cette recherche
|
||||
// globale couvre aussi les écrans-MODÈLES (un élément de jeu réutilisable,
|
||||
// ex. "mail content", posé sur une scène) : un déclencheur "Au clic"/"Au
|
||||
// survol" posé dans l'éditeur du modèle, sur un de SES PROPRES enfants,
|
||||
// est donc retrouvé même quand ce modèle est utilisé ailleurs — d'où le
|
||||
// besoin de renvoyer aussi l'écran d'origine de CHAQUE nœud (screenIdKey),
|
||||
// pas seulement le nœud : runFlowFrom() doit exécuter le graphe dans CET
|
||||
// écran-là (celui du modèle), jamais dans celui réellement affiché, sans
|
||||
// quoi le nœud trouvé n'existerait pas dans le graphe utilisé et l'action
|
||||
// ne se déclencherait jamais.
|
||||
function findTriggerNodess(elementId, event) {
|
||||
const found = [];
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes.forEach(function(n) {
|
||||
if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
|
||||
found.push({ node: n, screenId: screenIdKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Ré-attache les gestionnaires de clic sur chaque élément — appelé au
|
||||
// chargement, puis à nouveau après un rafraîchissement des données (le
|
||||
// DOM d'un écran est reconstruit après une action "Modifier une donnée",
|
||||
// les nœuds précédents et leurs écouteurs disparaissent avec).
|
||||
// 3.1 (Confort) — interactions au survol : échange le texte affiché
|
||||
// contre data-hover-text pendant que la souris survole l'élément, puis le
|
||||
// restaure au départ de la souris. On ignore volontairement les éléments
|
||||
// qui ont des enfants (ex. un conteneur) pour ne jamais écraser une mise
|
||||
// en page imbriquée avec du texte brut — cette fonctionnalité vise les
|
||||
// éléments de texte simples (nom, lien...), pas les conteneurs.
|
||||
function bindHoverTexts() {
|
||||
document.querySelectorAll('[data-hover-text]').forEach(function(el) {
|
||||
if (el.dataset.hoverBound) return;
|
||||
if (el.children.length > 0) return;
|
||||
el.dataset.hoverBound = '1';
|
||||
var original = el.textContent;
|
||||
el.addEventListener('mouseenter', function(){ el.textContent = el.dataset.hoverText; });
|
||||
el.addEventListener('mouseleave', function(){ el.textContent = original; });
|
||||
});
|
||||
}
|
||||
|
||||
function bindClicks() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const triggers = findTriggerNodess(elementId, 'clic');
|
||||
if (triggers.length) {
|
||||
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
|
||||
// après chaque changement de donnée, mais un élément qui n'est ni un
|
||||
// Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement
|
||||
// à l'autre (seul son innerHTML change pour ceux-là, voir
|
||||
// refreshRuntimeData()) — sans ce garde-fou, addEventListener('click')
|
||||
// s'empilerait à chaque rafraîchissement et un seul clic sur un
|
||||
// bouton "+10 points" finirait par déclencher l'action 2, 3, 10 fois
|
||||
// d'affilée (d'où les sauts de +100/-20/0 constatés au lieu de ±10).
|
||||
if (elDiv.dataset.clickBound) return;
|
||||
elDiv.dataset.clickBound = '1';
|
||||
elDiv.style.cursor = 'pointer';
|
||||
elDiv.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
// Si l'élément cliqué (ou un de ses parents, jusqu'au Répéteur
|
||||
// qui reçoit le gestionnaire) est en fait une LIGNE de Répéteur,
|
||||
// on retrouve son vrai id de donnée (data-row-id, posé par
|
||||
// render_repeater.py) — c'est ce qui permet à l'action "Ouvrir la
|
||||
// ligne cliquée" de savoir sur quel mail précis on a cliqué,
|
||||
// alors même que le déclencheur, lui, vise le Répéteur entier.
|
||||
const rowEl = ev.target.closest('.repeaterItem');
|
||||
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Déclencheurs "Au survol" / "Fin du survol" — même principe que
|
||||
// bindClicks() (garde-fou anti-doublon compris), mais sur mouseenter/
|
||||
// mouseleave plutôt que click, et les deux événements cherchés
|
||||
// séparément puisque ce sont deux déclencheurs distincts et indépendants
|
||||
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
|
||||
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
|
||||
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
|
||||
function bindHoverTriggers() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
if (elDiv.dataset.hoverTriggerBound) return;
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
|
||||
if (!enterTriggers.length && !leaveTriggers.length) return;
|
||||
elDiv.dataset.hoverTriggerBound = '1';
|
||||
// Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
|
||||
// le commentaire de findTriggerNodess (plusieurs déclencheurs
|
||||
// indépendants sur le même élément/événement doivent tous s'exécuter).
|
||||
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
|
||||
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
|
||||
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
|
||||
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
|
||||
// panneau rétractable/redimensionnable que "Logique de la scène"
|
||||
// ci-dessus, positionné juste en dessous. ----------
|
||||
|
||||
// ANIM_CLIPS/ANIMATE_CSS_LABELS/GAME_SLUG : voir le petit <script> inline
|
||||
// en tête de screen_edit.html (données Jinja).
|
||||
var ANIM_SCREEN_ID = SCREEN_ID;
|
||||
var ANIM_PX_PER_SEC = 60;
|
||||
var animCurrentClipId = null;
|
||||
|
||||
function animUrl(path) { return "/game/" + GAME_SLUG + path; }
|
||||
|
||||
function animClipLabel(clip) {
|
||||
if (clip.kind === 'custom') return '🎨 Personnalisée';
|
||||
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name);
|
||||
}
|
||||
|
||||
function renderAnimTimeline() {
|
||||
var lanesWrap = document.getElementById('animTracks');
|
||||
var ticksWrap = document.getElementById('animRuler');
|
||||
if (!lanesWrap || !ticksWrap) return;
|
||||
lanesWrap.innerHTML = '';
|
||||
ticksWrap.innerHTML = '';
|
||||
|
||||
var maxTime = ANIM_CLIPS.reduce(function(m, c) { return Math.max(m, (c.start_time || 0) + (c.duration || 1)); }, 5);
|
||||
var totalSeconds = Math.ceil(maxTime) + 2;
|
||||
var totalWidth = totalSeconds * ANIM_PX_PER_SEC;
|
||||
|
||||
var spacer = document.createElement('div');
|
||||
spacer.className = 'animRulerSpacer';
|
||||
ticksWrap.appendChild(spacer);
|
||||
var ticksLane = document.createElement('div');
|
||||
ticksLane.className = 'animRulerTicksWrap';
|
||||
ticksLane.style.width = totalWidth + 'px';
|
||||
for (var s = 0; s <= totalSeconds; s++) {
|
||||
var tick = document.createElement('div');
|
||||
tick.className = 'animRulerTick';
|
||||
tick.style.left = (s * ANIM_PX_PER_SEC) + 'px';
|
||||
tick.textContent = s + 's';
|
||||
ticksLane.appendChild(tick);
|
||||
}
|
||||
ticksWrap.appendChild(ticksLane);
|
||||
|
||||
var elementIds = [];
|
||||
ANIM_CLIPS.forEach(function(c) { if (elementIds.indexOf(c.element_id) === -1) elementIds.push(c.element_id); });
|
||||
|
||||
if (!elementIds.length) {
|
||||
var empty = document.createElement('p');
|
||||
empty.className = 'hint';
|
||||
empty.style.padding = '10px 0';
|
||||
empty.textContent = "Aucun clip pour l'instant — choisis un élément ci-dessus et clique « Ajouter un clip ».";
|
||||
lanesWrap.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
elementIds.forEach(function(elId) {
|
||||
var track = document.createElement('div');
|
||||
track.className = 'animTrack';
|
||||
|
||||
var label = document.createElement('div');
|
||||
label.className = 'animTrackLabel';
|
||||
label.textContent = ELEMENTS_LABELS[elId] || ('elt-' + elId);
|
||||
track.appendChild(label);
|
||||
|
||||
var lane = document.createElement('div');
|
||||
lane.className = 'animTrackLane';
|
||||
lane.style.width = totalWidth + 'px';
|
||||
|
||||
ANIM_CLIPS.filter(function(c) { return c.element_id === elId; }).forEach(function(clip) {
|
||||
var block = document.createElement('div');
|
||||
block.className = 'animClip';
|
||||
block.style.left = ((clip.start_time || 0) * ANIM_PX_PER_SEC) + 'px';
|
||||
block.style.width = Math.max(24, (clip.duration || 1) * ANIM_PX_PER_SEC) + 'px';
|
||||
block.textContent = animClipLabel(clip);
|
||||
block.dataset.clipId = clip.id;
|
||||
block.addEventListener('mousedown', function(e) { onAnimClipMouseDown(e, clip.id); });
|
||||
var handle = document.createElement('div');
|
||||
handle.className = 'animClipResizeHandle';
|
||||
handle.addEventListener('mousedown', function(e) { onAnimClipResizeMouseDown(e, clip.id); });
|
||||
block.appendChild(handle);
|
||||
lane.appendChild(block);
|
||||
});
|
||||
|
||||
track.appendChild(lane);
|
||||
lanesWrap.appendChild(track);
|
||||
});
|
||||
}
|
||||
|
||||
function onAnimClipMouseDown(e, clipId) {
|
||||
if (e.target.classList.contains('animClipResizeHandle')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
||||
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
||||
var startX = e.clientX;
|
||||
var startLeft = parseFloat(block.style.left);
|
||||
var moved = false;
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX;
|
||||
if (Math.abs(dx) > 3) moved = true;
|
||||
block.style.left = Math.max(0, startLeft + dx) + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
if (moved) {
|
||||
var newStart = Math.round((parseFloat(block.style.left) / ANIM_PX_PER_SEC) * 10) / 10;
|
||||
clip.start_time = newStart;
|
||||
updateAnimClip(clipId, { start_time: newStart });
|
||||
} else {
|
||||
openAnimClipForm(clip);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function onAnimClipResizeMouseDown(e, clipId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
||||
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
||||
var startX = e.clientX;
|
||||
var startWidth = parseFloat(block.style.width);
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX;
|
||||
block.style.width = Math.max(10, startWidth + dx) + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
var newDuration = Math.round((parseFloat(block.style.width) / ANIM_PX_PER_SEC) * 10) / 10;
|
||||
clip.duration = newDuration;
|
||||
updateAnimClip(clipId, { duration: newDuration });
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function updateAnimClip(clipId, fields) {
|
||||
return fetch(animUrl('/animations/clips/' + clipId + '/update'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields)
|
||||
}).then(function(r) { return r.json(); }).then(function(updated) {
|
||||
var idx = ANIM_CLIPS.findIndex(function(c) { return c.id === updated.id; });
|
||||
if (idx > -1) ANIM_CLIPS[idx] = updated;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function addAnimTrack() {
|
||||
var elId = parseInt(document.getElementById('anim-new-track-element').value, 10);
|
||||
fetch(animUrl('/screens/' + ANIM_SCREEN_ID + '/animations/clips/add'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ element_id: elId, kind: 'animate_css', animate_name: 'fadeIn', start_time: 0, duration: 1 }),
|
||||
}).then(function(r) { return r.json(); }).then(function(clip) {
|
||||
ANIM_CLIPS.push(clip);
|
||||
renderAnimTimeline();
|
||||
openAnimClipForm(clip);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAnimClipKind() {
|
||||
var kind = document.getElementById('anim-clip-kind').value;
|
||||
document.getElementById('anim-clip-animate-css-fields').style.display = kind === 'animate_css' ? 'block' : 'none';
|
||||
document.getElementById('anim-clip-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function addAnimKeyframeRow(percent, cssText) {
|
||||
var wrap = document.getElementById('anim-clip-keyframes');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'fieldRow animKeyframeRow';
|
||||
var percentInput = document.createElement('input');
|
||||
percentInput.type = 'number'; percentInput.min = '0'; percentInput.max = '100'; percentInput.step = '1';
|
||||
percentInput.className = 'anim-kf-percent'; percentInput.style.flex = '0 0 70px';
|
||||
percentInput.value = percent != null ? percent : 0;
|
||||
var cssInput = document.createElement('textarea');
|
||||
cssInput.className = 'anim-kf-css'; cssInput.style.flex = '1'; cssInput.rows = 2;
|
||||
cssInput.placeholder = 'ex. opacity: 0; transform: scale(0.5);';
|
||||
cssInput.value = cssText || '';
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button'; removeBtn.className = 'smallBtn danger';
|
||||
removeBtn.textContent = '✕';
|
||||
removeBtn.onclick = function() { row.remove(); };
|
||||
row.appendChild(percentInput);
|
||||
row.appendChild(cssInput);
|
||||
row.appendChild(removeBtn);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
|
||||
function collectAnimKeyframes() {
|
||||
var rows = document.querySelectorAll('#anim-clip-keyframes .animKeyframeRow');
|
||||
var keyframes = [];
|
||||
rows.forEach(function(row) {
|
||||
var percent = parseFloat(row.querySelector('.anim-kf-percent').value) || 0;
|
||||
var cssText = row.querySelector('.anim-kf-css').value;
|
||||
var props = {};
|
||||
cssText.split(';').forEach(function(decl) {
|
||||
var parts = decl.split(':');
|
||||
if (parts.length === 2) {
|
||||
var prop = parts[0].trim(), val = parts[1].trim();
|
||||
if (prop && val) props[prop] = val;
|
||||
}
|
||||
});
|
||||
keyframes.push({ percent: percent, props: props });
|
||||
});
|
||||
return keyframes;
|
||||
}
|
||||
|
||||
function openAnimClipForm(clip) {
|
||||
animCurrentClipId = clip.id;
|
||||
document.getElementById('animClipForm').style.display = 'block';
|
||||
document.getElementById('animClipFormTitle').textContent = 'Clip — ' + (ELEMENTS_LABELS[clip.element_id] || ('elt-' + clip.element_id));
|
||||
document.getElementById('anim-clip-kind').value = clip.kind || 'animate_css';
|
||||
document.getElementById('anim-clip-animate-name').value = clip.animate_name || 'fadeIn';
|
||||
document.getElementById('anim-clip-start').value = clip.start_time || 0;
|
||||
document.getElementById('anim-clip-duration').value = clip.duration || 1;
|
||||
document.getElementById('anim-clip-delay').value = clip.delay || 0;
|
||||
document.getElementById('anim-clip-easing').value = clip.easing || 'ease';
|
||||
document.getElementById('anim-clip-iteration').value = clip.iteration_count || '1';
|
||||
document.getElementById('anim-clip-keyframes').innerHTML = '';
|
||||
var keyframes = [];
|
||||
if (clip.custom_keyframes) {
|
||||
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
||||
}
|
||||
if (!keyframes.length) keyframes = [{ percent: 0, props: { opacity: '0' } }, { percent: 100, props: { opacity: '1' } }];
|
||||
keyframes.forEach(function(k) {
|
||||
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
|
||||
addAnimKeyframeRow(k.percent, cssText);
|
||||
});
|
||||
toggleAnimClipKind();
|
||||
document.querySelectorAll('.animClip').forEach(function(b) {
|
||||
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
||||
});
|
||||
}
|
||||
|
||||
function closeAnimClipForm() {
|
||||
animCurrentClipId = null;
|
||||
document.getElementById('animClipForm').style.display = 'none';
|
||||
document.querySelectorAll('.animClip').forEach(function(b) { b.classList.remove('selected'); });
|
||||
}
|
||||
|
||||
function saveAnimClipForm() {
|
||||
if (!animCurrentClipId) return Promise.resolve();
|
||||
var kind = document.getElementById('anim-clip-kind').value;
|
||||
var fields = {
|
||||
kind: kind,
|
||||
animate_name: document.getElementById('anim-clip-animate-name').value,
|
||||
start_time: parseFloat(document.getElementById('anim-clip-start').value) || 0,
|
||||
duration: parseFloat(document.getElementById('anim-clip-duration').value) || 1,
|
||||
delay: parseFloat(document.getElementById('anim-clip-delay').value) || 0,
|
||||
easing: document.getElementById('anim-clip-easing').value,
|
||||
iteration_count: document.getElementById('anim-clip-iteration').value,
|
||||
};
|
||||
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
||||
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
||||
}
|
||||
|
||||
function deleteAnimClipForm() {
|
||||
if (!animCurrentClipId) return;
|
||||
if (!confirm('Supprimer ce clip ?')) return;
|
||||
var id = animCurrentClipId;
|
||||
fetch(animUrl('/animations/clips/' + id + '/delete'), { method: 'POST' }).then(function() {
|
||||
ANIM_CLIPS = ANIM_CLIPS.filter(function(c) { return c.id !== id; });
|
||||
closeAnimClipForm();
|
||||
renderAnimTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
function previewAnimClip() {
|
||||
if (!animCurrentClipId) return;
|
||||
saveAnimClipForm().then(function() {
|
||||
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
||||
if (!clip) return;
|
||||
var target = document.querySelector('#canvas [data-anim-target="' + clip.element_id + '"]');
|
||||
if (!target) return;
|
||||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||||
var iterationCount = clip.iteration_count || '1';
|
||||
if (clip.kind === 'custom') {
|
||||
var keyframes = [];
|
||||
try { keyframes = JSON.parse(clip.custom_keyframes || '[]'); } catch (e) { /* ignore */ }
|
||||
var steps = keyframes.map(function(k) {
|
||||
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
||||
return k.percent + '% { ' + props + ' }';
|
||||
}).join(' ');
|
||||
var styleTag = document.getElementById('animEditorPreviewStyle');
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = 'animEditorPreviewStyle';
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
styleTag.textContent = '@keyframes animEditorPreview { ' + steps + ' }';
|
||||
target.style.animation = 'animEditorPreview ' + duration + 's ' + (clip.easing || 'ease') + ' ' + delay + 's ' + iterationCount;
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.classList.remove('animate__animated', animClass);
|
||||
target.removeEventListener('animationend', handler);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
|
||||
// panneau de logique — voir le commentaire Jinja au-dessus de .builderTabs.
|
||||
var HAS_ANIM_PANEL = !!document.getElementById('animTabPanel');
|
||||
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
|
||||
@@ -0,0 +1,616 @@
|
||||
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
|
||||
//
|
||||
// FLOW_NODES_INITIAL/FLOW_EDGES_INITIAL/FLOW_BLOCKS/ELEMENTS_LABELS/
|
||||
// SCREENS_MAP/*_LABELS_MAP/CUSTOM_EVENTS_MAP/GAME_SLUG/SCREEN_ID sont
|
||||
// posés par le petit <script> inline en tête de screen_edit.html (données
|
||||
// Jinja) — voir static/js/play/ pour le même patron déjà en place côté
|
||||
// moteur de jeu.
|
||||
var FLOW_SCREEN_ID = SCREEN_ID;
|
||||
// Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les
|
||||
// nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ;
|
||||
// un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas
|
||||
// besoin d'une seconde copie filtrée à tenir synchronisée, currentBlockNodes()/
|
||||
// currentBlockEdges() recalculent la vue du bloc ouvert à chaque rendu.
|
||||
var ALL_FLOW = {
|
||||
nodes: FLOW_NODES_INITIAL,
|
||||
edges: FLOW_EDGES_INITIAL,
|
||||
};
|
||||
var CURRENT_BLOCK_ID = null;
|
||||
|
||||
function currentBlockNodes() {
|
||||
return ALL_FLOW.nodes.filter(function(n) { return n.block_id === CURRENT_BLOCK_ID; });
|
||||
}
|
||||
function currentBlockEdges() {
|
||||
var ids = {};
|
||||
currentBlockNodes().forEach(function(n) { ids[n.id] = true; });
|
||||
return ALL_FLOW.edges.filter(function(e) { return ids[e.from_node_id] && ids[e.to_node_id]; });
|
||||
}
|
||||
|
||||
var flowPendingPort = null; // {nodeId, port} en attente de connexion
|
||||
|
||||
function flowUrl(path) { return "/game/" + GAME_SLUG + path; }
|
||||
|
||||
function nodeLabel(node) {
|
||||
if (node.node_type === 'trigger') {
|
||||
if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage'];
|
||||
if (node.trigger_event === 'evenement') {
|
||||
var listenedEvent = CUSTOM_EVENTS_MAP[node.trigger_custom_event_id];
|
||||
return '📣 Sur « ' + (listenedEvent ? listenedEvent.name : '?') + ' »';
|
||||
}
|
||||
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
|
||||
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
|
||||
}
|
||||
if (node.node_type === 'condition') {
|
||||
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 || '');
|
||||
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.
|
||||
try {
|
||||
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 || '');
|
||||
}).join(combLabel);
|
||||
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
|
||||
}
|
||||
return mainClause;
|
||||
}
|
||||
// action
|
||||
var base = '⚡ ' + (ACTION_TYPE_LABELS_MAP[node.action_type] || node.action_type);
|
||||
if (node.action_type === 'aller_a') {
|
||||
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?');
|
||||
} else if (node.action_type === 'ouvrir_ligne') {
|
||||
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
|
||||
} else if (node.action_type === 'activer_onglet') {
|
||||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
|
||||
} else if (node.action_type === 'modifier_element') {
|
||||
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
|
||||
if (node.element_property === 'visibilite') {
|
||||
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
|
||||
} else if (node.element_property === 'surbrillance') {
|
||||
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
|
||||
} else if (node.element_property === 'desactive') {
|
||||
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
|
||||
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
|
||||
var vparts = node.element_value.split('||');
|
||||
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
|
||||
} else {
|
||||
base += ' = ' + node.element_value;
|
||||
}
|
||||
} else if (node.action_type === 'modifier_donnee') {
|
||||
base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
|
||||
if (node.data_value) base += ' ' + node.data_value;
|
||||
} else if (node.action_type === 'modifier_variable') {
|
||||
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
|
||||
if (node.data_value) base += ' ' + node.data_value;
|
||||
} else if (node.action_type === 'attendre') {
|
||||
base += ' (' + (node.data_value || '0') + ' s)';
|
||||
} else if (node.action_type === 'declencher_evenement') {
|
||||
// Notification pure : rien d'autre à afficher que le nom de
|
||||
// l'événement (voir screens/custom_events/) — aucun élément/ligne
|
||||
// n'est jamais transmis au déclenchement.
|
||||
var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id];
|
||||
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function renderFlow() {
|
||||
var canvas = document.getElementById('flowCanvas');
|
||||
var svg = document.getElementById('flowEdges');
|
||||
canvas.innerHTML = '';
|
||||
svg.innerHTML = '';
|
||||
|
||||
currentBlockNodes().forEach(function(node) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'flowNode flowNode-' + node.node_type;
|
||||
div.style.left = node.x + 'px';
|
||||
div.style.top = node.y + 'px';
|
||||
div.dataset.nodeId = node.id;
|
||||
|
||||
var del = document.createElement('button');
|
||||
del.className = 'flowNodeDelete';
|
||||
del.type = 'button';
|
||||
del.textContent = '✕';
|
||||
del.onclick = function(ev) { ev.stopPropagation(); deleteFlowNode(node.id); };
|
||||
div.appendChild(del);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'flowNodeBody';
|
||||
body.textContent = nodeLabel(node);
|
||||
div.appendChild(body);
|
||||
|
||||
if (node.node_type !== 'trigger') {
|
||||
var inPort = document.createElement('div');
|
||||
inPort.className = 'flowPort flowPortIn';
|
||||
inPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'in', null); };
|
||||
div.appendChild(inPort);
|
||||
}
|
||||
if (node.node_type === 'condition') {
|
||||
var outTrue = document.createElement('div');
|
||||
outTrue.className = 'flowPort flowPortOut flowPortTrue';
|
||||
outTrue.title = 'Vrai';
|
||||
outTrue.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'vrai'); };
|
||||
div.appendChild(outTrue);
|
||||
var outFalse = document.createElement('div');
|
||||
outFalse.className = 'flowPort flowPortOut flowPortFalse';
|
||||
outFalse.title = 'Faux';
|
||||
outFalse.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'faux'); };
|
||||
div.appendChild(outFalse);
|
||||
} else {
|
||||
var outPort = document.createElement('div');
|
||||
outPort.className = 'flowPort flowPortOut';
|
||||
outPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'out'); };
|
||||
div.appendChild(outPort);
|
||||
}
|
||||
|
||||
div.addEventListener('mousedown', function(e) { onFlowNodeMouseDown(e, node.id); });
|
||||
canvas.appendChild(div);
|
||||
});
|
||||
|
||||
// Redessine les fils après que les nœuds soient dans le DOM (pour lire leur position réelle).
|
||||
requestAnimationFrame(drawFlowEdges);
|
||||
}
|
||||
|
||||
function portPosition(nodeId, side, sub) {
|
||||
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
|
||||
if (!div) return null;
|
||||
var wrap = document.querySelector('.flowCanvasWrap');
|
||||
var wrapRect = wrap.getBoundingClientRect();
|
||||
var selector = side === 'in' ? '.flowPortIn' : (sub === 'vrai' ? '.flowPortTrue' : (sub === 'faux' ? '.flowPortFalse' : '.flowPortOut'));
|
||||
var port = div.querySelector(selector);
|
||||
if (!port) return null;
|
||||
var r = port.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2 - wrapRect.left + wrap.scrollLeft, y: r.top + r.height / 2 - wrapRect.top + wrap.scrollTop };
|
||||
}
|
||||
|
||||
function drawFlowEdges() {
|
||||
var svg = document.getElementById('flowEdges');
|
||||
svg.innerHTML = '';
|
||||
currentBlockEdges().forEach(function(edge) {
|
||||
var from = portPosition(edge.from_node_id, 'out', edge.from_port);
|
||||
var to = portPosition(edge.to_node_id, 'in', null);
|
||||
if (!from || !to) return;
|
||||
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', from.x); line.setAttribute('y1', from.y);
|
||||
line.setAttribute('x2', to.x); line.setAttribute('y2', to.y);
|
||||
line.setAttribute('class', 'flowEdgeLine' + (edge.from_port === 'faux' ? ' flowEdgeFalse' : (edge.from_port === 'vrai' ? ' flowEdgeTrue' : '')));
|
||||
line.style.pointerEvents = 'stroke';
|
||||
line.addEventListener('click', function() { deleteFlowEdge(edge.id); });
|
||||
svg.appendChild(line);
|
||||
});
|
||||
}
|
||||
|
||||
function onPortClick(nodeId, side, port) {
|
||||
if (side === 'out') {
|
||||
flowPendingPort = { nodeId: nodeId, port: port };
|
||||
return;
|
||||
}
|
||||
// side === 'in'
|
||||
if (!flowPendingPort) return;
|
||||
if (flowPendingPort.nodeId === nodeId) { flowPendingPort = null; return; }
|
||||
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/edges/add'), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId })
|
||||
}).then(function(r){ return r.json(); }).then(function(edge){
|
||||
ALL_FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId });
|
||||
flowPendingPort = null;
|
||||
drawFlowEdges();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFlowEdge(edgeId) {
|
||||
fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){
|
||||
ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.id !== edgeId; });
|
||||
drawFlowEdges();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFlowNode(nodeId) {
|
||||
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
|
||||
ALL_FLOW.nodes = ALL_FLOW.nodes.filter(function(n){ return n.id !== nodeId; });
|
||||
ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; });
|
||||
renderFlow();
|
||||
});
|
||||
}
|
||||
|
||||
function onFlowNodeMouseDown(e, nodeId) {
|
||||
if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return;
|
||||
e.preventDefault();
|
||||
var node = ALL_FLOW.nodes.find(function(n){ return n.id === nodeId; });
|
||||
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = node.x, startTop = node.y;
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
node.x = Math.max(0, startLeft + dx);
|
||||
node.y = Math.max(0, startTop + dy);
|
||||
div.style.left = node.x + 'px';
|
||||
div.style.top = node.y + 'px';
|
||||
drawFlowEdges();
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
fetch(flowUrl('/flow/nodes/' + nodeId + '/move'), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ x: node.x, y: node.y })
|
||||
});
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
// ---------- Formulaire d'ajout de nœud ----------
|
||||
|
||||
var currentNodeFormType = null;
|
||||
|
||||
function openNodeForm(type) {
|
||||
currentNodeFormType = type;
|
||||
document.getElementById('nodeForm').style.display = 'block';
|
||||
document.getElementById('nodeFormTitle').textContent =
|
||||
type === 'trigger' ? 'Nouveau déclencheur' : (type === 'condition' ? 'Nouvelle condition' : 'Nouvelle action');
|
||||
document.getElementById('nodeFormTrigger').style.display = type === 'trigger' ? 'block' : 'none';
|
||||
document.getElementById('nodeFormCondition').style.display = type === 'condition' ? 'block' : 'none';
|
||||
document.getElementById('nodeFormAction').style.display = type === 'action' ? 'block' : 'none';
|
||||
if (type === 'trigger') toggleFlowTriggerFields();
|
||||
if (type === 'condition') {
|
||||
document.getElementById('nf-cond-source').value = 'objet';
|
||||
onFlowConditionSourceChange();
|
||||
onFlowDefinitionChange('cond');
|
||||
flowConditionExtraClauses = [];
|
||||
renderFlowConditionClauses();
|
||||
}
|
||||
if (type === 'action') {
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
toggleFlowActionFields();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFlowTriggerFields() {
|
||||
var eventVal = document.getElementById('nf-trigger-event').value;
|
||||
var isAffichage = eventVal === 'affichage';
|
||||
// "Sur un événement personnalisé" ne concerne ni un élément précis ni
|
||||
// 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';
|
||||
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
|
||||
document.getElementById('nf-trigger-event-row').style.display = isCustomEvent ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function closeNodeForm() {
|
||||
document.getElementById('nodeForm').style.display = 'none';
|
||||
currentNodeFormType = null;
|
||||
}
|
||||
|
||||
// CLICKED_ROW_ID (-1, voir screens/flow/constants.py) : "la ligne de
|
||||
// Répéteur sur laquelle on vient de cliquer" — pour un déclencheur "Au
|
||||
// clic" posé sur un Répéteur, chaque ligne exécute le MÊME graphe et la
|
||||
// ligne cliquée n'est jamais connue à l'avance ; choisir une ligne fixe
|
||||
// dans l'éditeur n'a donc pas de sens pour ce cas. Résolue au moment de
|
||||
// l'exécution (window.lastClickedRowId, voir play.html), pas à la
|
||||
// création du nœud — proposée en tête de TOUTE liste "Ligne concernée",
|
||||
// avant les lignes réelles de l'objet.
|
||||
var FLOW_CLICKED_ROW_ID = -1;
|
||||
|
||||
function _flowRowOptionsHtml(info) {
|
||||
var options = '<option value="' + FLOW_CLICKED_ROW_ID + '">🖱️ Ligne cliquée (Répéteur)</option>';
|
||||
options += info.rows.length
|
||||
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||||
: '<option value="" disabled>Aucune donnée dans cet objet</option>';
|
||||
return options;
|
||||
}
|
||||
|
||||
function onFlowDefinitionChange(prefix) {
|
||||
var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition');
|
||||
if (!defSel) return;
|
||||
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
|
||||
var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row');
|
||||
var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field');
|
||||
rowSel.innerHTML = _flowRowOptionsHtml(info);
|
||||
fieldSel.innerHTML = info.fields.length
|
||||
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||||
: '<option value="">Aucun champ simple</option>';
|
||||
}
|
||||
|
||||
function onFlowFieldChange() {
|
||||
// Pas d'action nécessaire ici pour l'instant : le type du champ est relu
|
||||
// directement sur l'option sélectionnée au moment de la création du nœud.
|
||||
}
|
||||
|
||||
// Bascule la clause PRINCIPALE d'un nœud Condition entre "Champ d'un
|
||||
// objet" (historique) et "Variable globale" — voir ensure_flow_schema.py
|
||||
// (cond_source) et evaluateConditionClause() dans play.html. Même bascule
|
||||
// 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';
|
||||
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';
|
||||
}
|
||||
|
||||
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
|
||||
// Chaque clause supplémentaire réutilise EXACTEMENT le même modèle que la
|
||||
// clause principale (nf-cond-definition/row/field/operator/value) mais dans
|
||||
// une ligne indépendante, numérotée par index dans flowConditionExtraClauses.
|
||||
var flowConditionExtraClauses = [];
|
||||
|
||||
function addFlowConditionClause() {
|
||||
var firstDefSel = document.getElementById('nf-cond-definition');
|
||||
var defaultDefId = firstDefSel && firstDefSel.value ? firstDefSel.value : '';
|
||||
flowConditionExtraClauses.push({
|
||||
source: 'objet', definition_id: defaultDefId, row_id: '', field: '', field_type: '',
|
||||
variable: '', variable_chemin: '', operator: 'egal', value: '',
|
||||
});
|
||||
renderFlowConditionClauses();
|
||||
}
|
||||
|
||||
function removeFlowConditionClause(idx) {
|
||||
flowConditionExtraClauses.splice(idx, 1);
|
||||
renderFlowConditionClauses();
|
||||
}
|
||||
|
||||
// Bascule une clause SUPPLÉMENTAIRE (indexée) entre "Champ d'un objet" et
|
||||
// "Variable globale" — même principe que onFlowConditionSourceChange() pour
|
||||
// la clause principale, scopé par index plutôt que par id fixe.
|
||||
function onFlowClauseSourceChange(idx) {
|
||||
var sel = document.getElementById('nf-cond-extra-source-' + idx);
|
||||
if (!sel) return;
|
||||
flowConditionExtraClauses[idx].source = sel.value;
|
||||
var isVariable = sel.value === 'variable';
|
||||
var objetFields = document.getElementById('nf-cond-extra-objet-fields-' + idx);
|
||||
var varFields = document.getElementById('nf-cond-extra-variable-fields-' + idx);
|
||||
if (objetFields) objetFields.style.display = isVariable ? 'none' : 'block';
|
||||
if (varFields) varFields.style.display = isVariable ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderFlowConditionClauses() {
|
||||
var wrap = document.getElementById('nf-cond-extra-clauses');
|
||||
var combinatorRow = document.getElementById('nf-cond-combinator-row');
|
||||
if (!wrap || !combinatorRow) return;
|
||||
combinatorRow.style.display = flowConditionExtraClauses.length ? 'block' : 'none';
|
||||
var defOptionsHtml = document.getElementById('nf-cond-definition') ? document.getElementById('nf-cond-definition').innerHTML : '';
|
||||
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
|
||||
var isVariable = clause.source === 'variable';
|
||||
return '' +
|
||||
'<div class="flowConditionClauseRow" style="border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;">' +
|
||||
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
|
||||
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
|
||||
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
|
||||
'</select></label>' +
|
||||
'<div id="nf-cond-extra-objet-fields-' + idx + '" style="display:' + (isVariable ? 'none' : 'block') + ';">' +
|
||||
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
|
||||
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
|
||||
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
|
||||
'</div>' +
|
||||
'<div id="nf-cond-extra-variable-fields-' + idx + '" style="display:' + (isVariable ? 'block' : 'none') + ';">' +
|
||||
'<label>Variable<select id="nf-cond-extra-variable-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].variable = this.value">' +
|
||||
'<option value="">(sélectionner une variable)</option>' + GLOBAL_VARIABLES_OPTIONS_HTML +
|
||||
'</select></label>' +
|
||||
'<label>Chemin (optionnel)<input type="text" id="nf-cond-extra-variable-chemin-' + idx + '" placeholder="ex. .nom ou [0].valeur" value="' + (clause.variable_chemin || '').replace(/"/g, '"') + '" onchange="flowConditionExtraClauses[' + idx + '].variable_chemin = this.value"></label>' +
|
||||
'</div>' +
|
||||
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
|
||||
CONDITION_OPERATORS_HTML +
|
||||
'</select></label>' +
|
||||
'<label>Valeur de comparaison<input type="text" id="nf-cond-extra-value-' + idx + '" placeholder="ex. 1, vrai, 10..." value="' + (clause.value || '').replace(/"/g, '"') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
|
||||
'<button type="button" class="smallBtn button is-small" onclick="removeFlowConditionClause(' + idx + ')">Retirer cette condition</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
flowConditionExtraClauses.forEach(function(clause, idx) {
|
||||
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
|
||||
if (defSel && clause.definition_id) defSel.value = clause.definition_id;
|
||||
onFlowClauseDefinitionChange(idx);
|
||||
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
|
||||
if (opSel) opSel.value = clause.operator || 'egal';
|
||||
var varSel = document.getElementById('nf-cond-extra-variable-' + idx);
|
||||
if (varSel && clause.variable) varSel.value = clause.variable;
|
||||
});
|
||||
}
|
||||
|
||||
function onFlowClauseDefinitionChange(idx) {
|
||||
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
|
||||
var rowSel = document.getElementById('nf-cond-extra-row-' + idx);
|
||||
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
|
||||
if (!defSel || !rowSel || !fieldSel) return;
|
||||
flowConditionExtraClauses[idx].definition_id = defSel.value;
|
||||
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
|
||||
rowSel.innerHTML = _flowRowOptionsHtml(info);
|
||||
fieldSel.innerHTML = info.fields.length
|
||||
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||||
: '<option value="">Aucun champ simple</option>';
|
||||
flowConditionExtraClauses[idx].row_id = rowSel.value;
|
||||
onFlowClauseFieldChange(idx);
|
||||
}
|
||||
|
||||
function onFlowClauseFieldChange(idx) {
|
||||
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
|
||||
if (!fieldSel) return;
|
||||
var opt = fieldSel.options[fieldSel.selectedIndex];
|
||||
flowConditionExtraClauses[idx].field = fieldSel.value;
|
||||
flowConditionExtraClauses[idx].field_type = opt ? opt.dataset.type : '';
|
||||
}
|
||||
|
||||
function toggleFlowActionFields() {
|
||||
var typeSel = document.getElementById('nf-action-type');
|
||||
var screenSel = document.getElementById('nf-action-target-screen');
|
||||
var elementFields = document.getElementById('nf-action-element-fields');
|
||||
var dataFields = document.getElementById('nf-action-data-fields');
|
||||
var variableFields = document.getElementById('nf-action-variable-fields');
|
||||
var waitFields = document.getElementById('nf-action-wait-fields');
|
||||
var eventFields = document.getElementById('nf-action-event-fields');
|
||||
var isOnglet = typeSel.value === 'activer_onglet';
|
||||
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
|
||||
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
|
||||
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
|
||||
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
|
||||
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
|
||||
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
|
||||
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
|
||||
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
|
||||
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
|
||||
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
|
||||
}
|
||||
|
||||
function toggleFlowElementPropertyValue() {
|
||||
var propSel = document.getElementById('nf-action-element-property');
|
||||
var visSel = document.getElementById('nf-action-element-value-visibility');
|
||||
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
|
||||
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
|
||||
var colorInp = document.getElementById('nf-action-element-value-color');
|
||||
var numInp = document.getElementById('nf-action-element-value-number');
|
||||
var textInp = document.getElementById('nf-action-element-value-text');
|
||||
var toggleRow = document.getElementById('nf-action-element-toggle-row');
|
||||
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
|
||||
if (propSel.value === 'visibilite') {
|
||||
visSel.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
} else if (propSel.value === 'surbrillance') {
|
||||
surbrillanceSel.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
} else if (propSel.value === 'desactive') {
|
||||
desactiveSel.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
} else if (propSel.value === 'contenu') {
|
||||
textInp.style.display = 'block';
|
||||
toggleRow.style.display = 'none';
|
||||
document.getElementById('nf-action-element-toggle').checked = false;
|
||||
} else if (propSel.value === 'bg_color' || propSel.value === 'border_color') {
|
||||
colorInp.style.display = 'block';
|
||||
toggleRow.style.display = 'flex';
|
||||
} else {
|
||||
numInp.style.display = 'block';
|
||||
toggleRow.style.display = 'flex';
|
||||
}
|
||||
toggleFlowElementToggleMode();
|
||||
}
|
||||
|
||||
function toggleFlowElementToggleMode() {
|
||||
var propSel = document.getElementById('nf-action-element-property');
|
||||
var checked = document.getElementById('nf-action-element-toggle').checked;
|
||||
var wrap = document.getElementById('nf-action-element-value2-wrap');
|
||||
var colorInp2 = document.getElementById('nf-action-element-value2-color');
|
||||
var numInp2 = document.getElementById('nf-action-element-value2-number');
|
||||
var isColor = propSel.value === 'bg_color' || propSel.value === 'border_color';
|
||||
wrap.style.display = checked ? 'block' : 'none';
|
||||
colorInp2.style.display = (checked && isColor) ? 'block' : 'none';
|
||||
numInp2.style.display = (checked && !isColor) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function submitNodeForm() {
|
||||
var payload = { node_type: currentNodeFormType, x: 20, y: 20 + currentBlockNodes().length * 10, block_id: CURRENT_BLOCK_ID };
|
||||
|
||||
if (currentNodeFormType === 'trigger') {
|
||||
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 !== 'affichage') {
|
||||
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
|
||||
}
|
||||
} 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;
|
||||
} 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) {
|
||||
// 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'
|
||||
? { source: 'variable', variable: payload.cond_variable, variable_chemin: payload.cond_variable_chemin,
|
||||
operator: payload.cond_operator, value: payload.cond_value }
|
||||
: { source: 'objet', definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
|
||||
field: payload.cond_field, field_type: payload.cond_field_type,
|
||||
operator: payload.cond_operator, value: payload.cond_value };
|
||||
var allClauses = [mainClause].concat(flowConditionExtraClauses.map(function(c) {
|
||||
return c.source === 'variable'
|
||||
? { source: 'variable', variable: c.variable, variable_chemin: c.variable_chemin, operator: c.operator, value: c.value }
|
||||
: { source: 'objet', definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
|
||||
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value };
|
||||
}));
|
||||
payload.cond_clauses = JSON.stringify(allClauses);
|
||||
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
|
||||
}
|
||||
} else if (currentNodeFormType === 'action') {
|
||||
var actionType = document.getElementById('nf-action-type').value;
|
||||
payload.action_type = actionType;
|
||||
if (actionType === 'aller_a' || actionType === 'ouvrir_ligne') {
|
||||
payload.target_screen_id = parseInt(document.getElementById('nf-action-target-screen').value, 10);
|
||||
} else if (actionType === 'activer_onglet') {
|
||||
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
|
||||
} else if (actionType === 'modifier_element') {
|
||||
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
|
||||
var prop = document.getElementById('nf-action-element-property').value;
|
||||
payload.element_property = prop;
|
||||
var isColorProp = prop === 'bg_color' || prop === 'border_color';
|
||||
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && document.getElementById('nf-action-element-toggle').checked;
|
||||
if (prop === 'visibilite') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
|
||||
} else if (prop === 'surbrillance') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
|
||||
} else if (prop === 'desactive') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
|
||||
} else if (prop === 'contenu') {
|
||||
payload.element_value = document.getElementById('nf-action-element-value-text').value;
|
||||
} else {
|
||||
var v1 = isColorProp
|
||||
? document.getElementById('nf-action-element-value-color').value
|
||||
: document.getElementById('nf-action-element-value-number').value;
|
||||
if (toggleOn) {
|
||||
var v2 = isColorProp
|
||||
? document.getElementById('nf-action-element-value2-color').value
|
||||
: document.getElementById('nf-action-element-value2-number').value;
|
||||
payload.element_value = v1 + '||' + v2;
|
||||
} else {
|
||||
payload.element_value = v1;
|
||||
}
|
||||
}
|
||||
} else if (actionType === 'modifier_donnee') {
|
||||
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
|
||||
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
|
||||
payload.target_field = document.getElementById('nf-action-data-field').value;
|
||||
payload.data_operation = document.getElementById('nf-action-data-operation').value;
|
||||
payload.data_value = document.getElementById('nf-action-data-value').value;
|
||||
} else if (actionType === 'modifier_variable') {
|
||||
payload.target_variable = document.getElementById('nf-action-variable-name').value;
|
||||
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
|
||||
payload.data_value = document.getElementById('nf-action-variable-value').value;
|
||||
} else if (actionType === 'attendre') {
|
||||
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
|
||||
} else if (actionType === 'declencher_evenement') {
|
||||
// Notification pure : aucun élément/ligne à transmettre (voir
|
||||
// screens/custom_events/) — c'est à l'écouteur de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(node){
|
||||
ALL_FLOW.nodes.push(node);
|
||||
closeNodeForm();
|
||||
renderFlow();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
function initBuilderPanel() {
|
||||
(function () {
|
||||
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
|
||||
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
|
||||
// pos-w/pos-h n'existent pas pour un élément posé dans un conteneur (sa
|
||||
// taille se règle via les champs "Largeur/Hauteur (%)"/"fixe (px)" du
|
||||
// formulaire général à la place, voir size_ctrls plus haut) — X/Y non
|
||||
// plus, mais ils restent dans le DOM (juste désactivés) pour cet élément.
|
||||
if (!xEl || !wEl) return;
|
||||
function apply() {
|
||||
var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID);
|
||||
var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0;
|
||||
var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1;
|
||||
if (canvasEl) {
|
||||
canvasEl.style.left = x + '%'; canvasEl.style.top = y + '%';
|
||||
canvasEl.style.width = w + '%'; canvasEl.style.height = h + '%';
|
||||
}
|
||||
saveGeometry(CURRENT_SELECTED_ID, x, y, w, h);
|
||||
}
|
||||
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
|
||||
})();
|
||||
|
||||
bindAspectButtons();
|
||||
|
||||
document.querySelectorAll('.uploadInput').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
data.append('file', input.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (json.url) {
|
||||
targetField.value = json.url;
|
||||
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
|
||||
// Enregistre tout de suite, comme n'importe quel autre réglage —
|
||||
// un fichier envoyé ne doit jamais rester "en attente".
|
||||
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
|
||||
}
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
bindPropsAutosave();
|
||||
bindJaugeDefinitionSelect();
|
||||
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
|
||||
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
|
||||
bindClauseListDefinitionSelect('field-data_definition_id');
|
||||
bindClauseListRows();
|
||||
initFilterValuePickers();
|
||||
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
||||
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
|
||||
restoreTreeCollapsedState();
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
|
||||
restoreFloatPanelState('left', 'panelLeftFloat', 'panelLeftReopenBtn');
|
||||
}
|
||||
|
||||
// Jauge : quand on change l'objet de données à suivre SANS quitter le
|
||||
// panneau (donc sans le rechargement serveur qui repeuplerait row_id/champ/
|
||||
// champ_nom via controls_with_values.py), on reconstruit ces trois menus à
|
||||
// la volée à partir de DEFINITIONS_DATA (même source que les formulaires de
|
||||
// la Logique de la scène, voir onFlowDefinitionChange). DEFINITIONS_DATA
|
||||
// est déclaré plus bas dans ce script (donc encore "undefined" au moment où
|
||||
// initBuilderPanel() tourne en tout premier) — sans conséquence ici,
|
||||
// puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind.
|
||||
// Libellé d'un champ dans une liste déroulante — précise l'objet visé pour
|
||||
// un champ "relation" (ex. "parcour (→ parcours)"), même règle que
|
||||
// _field_label() côté Python (controls_with_values.py).
|
||||
function fieldOptionLabel(f) {
|
||||
return (f.type === 'relation' && f.relation_definition_name) ? (f.name + ' (→ ' + f.relation_definition_name + ')') : f.name;
|
||||
}
|
||||
|
||||
function bindJaugeDefinitionSelect() {
|
||||
var defSel = document.getElementById('field-definition_id');
|
||||
var rowSel = document.getElementById('field-row_id');
|
||||
var champSel = document.getElementById('field-champ');
|
||||
var champNomSel = document.getElementById('field-champ_nom');
|
||||
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
|
||||
rowSel.innerHTML = info.rows.length
|
||||
? info.rows.map(function (r) { return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||||
// La Jauge affiche une valeur/un nom tel quel — un champ "relation" (un
|
||||
// id, pas une valeur) n'a pas sa place ici, même exclusion que côté
|
||||
// serveur (voir controls_with_values.py).
|
||||
var simpleFields = info.fields.filter(function (f) { return f.type !== 'relation'; });
|
||||
var fieldOptions = simpleFields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join('');
|
||||
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
|
||||
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Réglages "champ" d'un filtre (Répéteur/Donnée liée) : listes
|
||||
// déroulantes peuplées avec les VRAIS champs de l'objet choisi, jamais
|
||||
// tapées à la main — même principe que bindJaugeDefinitionSelect() ci-dessus,
|
||||
// généralisé pour s'appliquer à un nombre quelconque de listes "champ"
|
||||
// rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ,
|
||||
// ou data_filtre_champ + data_filtre2_champ). ----------
|
||||
// ---------- Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) :
|
||||
// n'affiche que les réglages pertinents pour le mode choisi (variable
|
||||
// globale, ou champ d'un objet) — les deux autres restent en base (peu
|
||||
// importe, seul le mode courant est lu par is_element_visible()) mais sont
|
||||
// masqués pour ne pas embrouiller. ----------
|
||||
function toggleVisCondFields(sel) {
|
||||
var mode = sel.value;
|
||||
document.querySelectorAll('.visCondRow').forEach(function (row) {
|
||||
var scope = row.dataset.visCondScope;
|
||||
var show = scope === 'any' ? !!mode : scope === mode;
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
var defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
var fieldSels = fieldSelIds.map(function (id) { return document.getElementById(id); }).filter(Boolean);
|
||||
if (!fieldSels.length) return;
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
|
||||
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
|
||||
fieldSels.forEach(function (sel) { sel.innerHTML = opts; });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/
|
||||
// valeur), combinées entre elles par ET/OU (voir c_clause_list.py,
|
||||
// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante
|
||||
// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une
|
||||
// condition" clone un <template> pour en ajouter de nouvelles à la volée,
|
||||
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
|
||||
// ailleurs dans l'outil. ----------
|
||||
function wireClauseRow(row, key) {
|
||||
row.querySelectorAll('input, select').forEach(function (field) {
|
||||
var debounced = field.tagName === 'INPUT';
|
||||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
});
|
||||
});
|
||||
var removeBtn = row.querySelector('.clauseRemoveBtn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function () {
|
||||
row.remove();
|
||||
updateClauseCombinatorVisibility(key);
|
||||
submitPropsForm();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateClauseCombinatorVisibility(key) {
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
var combRow = document.getElementById('clauseCombinatorRow-' + key);
|
||||
if (!wrap || !combRow) return;
|
||||
combRow.style.display = wrap.querySelectorAll('.clauseRow').length > 1 ? '' : 'none';
|
||||
}
|
||||
|
||||
function addClauseRow(key) {
|
||||
var tmpl = document.getElementById('clauseRowTemplate-' + key);
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
if (!tmpl || !wrap) return;
|
||||
var clone = tmpl.content.cloneNode(true);
|
||||
var row = clone.querySelector('.clauseRow');
|
||||
wireClauseRow(row, key);
|
||||
wrap.appendChild(clone);
|
||||
// Repeuple tout de suite son sélecteur "Champ" avec les VRAIS champs de
|
||||
// l'objet déjà choisi (sans ça, une ligne ajoutée après coup resterait
|
||||
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
||||
// déjà réglé plus haut dans le panneau).
|
||||
var defSel = document.getElementById('field-data_definition_id');
|
||||
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
|
||||
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
||||
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
||||
initFilterValuePickers();
|
||||
updateClauseCombinatorVisibility(key);
|
||||
submitPropsForm();
|
||||
}
|
||||
|
||||
// Rebranche les lignes déjà rendues par le serveur (rechargement du
|
||||
// panneau, ex. sélection d'un autre élément) — les lignes ajoutées EN
|
||||
// COURS de session par addClauseRow() se branchent déjà elles-mêmes.
|
||||
function bindClauseListRows() {
|
||||
document.querySelectorAll('.clauseListControl').forEach(function (control) {
|
||||
var key = control.dataset.clauseKey;
|
||||
control.querySelectorAll('.clauseRow').forEach(function (row) { wireClauseRow(row, key); });
|
||||
});
|
||||
}
|
||||
|
||||
// Repeuple le sélecteur "champ" de TOUTES les lignes de condition actuelles
|
||||
// (interrogées à chaque changement, donc couvre aussi les lignes ajoutées
|
||||
// après coup) quand on change l'objet lié — même principe que
|
||||
// bindDefinitionFieldSelects ci-dessus, généralisé pour un nombre de lignes
|
||||
// variable plutôt qu'une liste fixe d'ids capturée une fois pour toutes.
|
||||
function bindClauseListDefinitionSelect(defSelId) {
|
||||
var defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
|
||||
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
|
||||
document.querySelectorAll('.clauseChampSelect').forEach(function (sel) {
|
||||
var current = sel.value;
|
||||
sel.innerHTML = opts;
|
||||
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
|
||||
// objet ----------
|
||||
//
|
||||
// La valeur envoyée au serveur reste une simple chaîne "texte" (voir
|
||||
// _resolve_filter_value côté Python) — soit une valeur fixe, soit une
|
||||
// référence à un champ d'un autre objet dans un format interne précis. Le
|
||||
// mode "dynamique" ci-dessous se contente de reconstruire cette chaîne à
|
||||
// partir de deux listes déroulantes (objet, champ) plutôt que de la faire
|
||||
// taper à la main. OPEN/CLOSE évitent d'écrire les deux accolades l'une
|
||||
// contre l'autre dans le source (Jinja les interpréterait comme le DÉBUT
|
||||
// d'une expression s'il les rencontrait telles quelles).
|
||||
var _FILTER_REF_OPEN = '{' + '{';
|
||||
var _FILTER_REF_CLOSE = '}' + '}';
|
||||
var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
|
||||
// Une paire d'accolades doublées entourant "$nom_variable" (voir
|
||||
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
|
||||
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
|
||||
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
|
||||
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
|
||||
// variable, utile pour une variable "objet"/"tableau".
|
||||
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}\\[\\]]+)((?:\\.[^.{}\\[\\]]+|\\[\\d+\\])*)\\s*\\}\\}$');
|
||||
|
||||
function _filterValueDefinitionId(objName) {
|
||||
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
|
||||
return opt ? opt.dataset.definitionId : null;
|
||||
}
|
||||
|
||||
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
||||
var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
||||
fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) {
|
||||
return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + fieldOptionLabel(f) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateFilterValueFromDynamic(wrap) {
|
||||
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
var input = wrap.querySelector('.filterValueFixe input');
|
||||
input.value = (objSel.value && fieldSel.value) ? (_FILTER_REF_OPEN + objSel.value + '.' + fieldSel.value + _FILTER_REF_CLOSE) : '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function updateFilterValueFromVariable(wrap) {
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var pathInput = wrap.querySelector('.filterValueVarPath');
|
||||
var input = wrap.querySelector('.filterValueFixe input');
|
||||
var path = pathInput ? pathInput.value.trim() : '';
|
||||
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + path + _FILTER_REF_CLOSE) : '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function onFilterValueModeChange(sel) {
|
||||
var wrap = sel.closest('.filterValueControl');
|
||||
var fixe = wrap.querySelector('.filterValueFixe');
|
||||
var dyn = wrap.querySelector('.filterValueDynamique');
|
||||
var varWrap = wrap.querySelector('.filterValueVariable');
|
||||
fixe.style.display = sel.value === 'fixe' ? 'block' : 'none';
|
||||
dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none';
|
||||
varWrap.style.display = sel.value === 'variable' ? 'block' : 'none';
|
||||
if (sel.value === 'dynamique') updateFilterValueFromDynamic(wrap);
|
||||
else if (sel.value === 'variable') updateFilterValueFromVariable(wrap);
|
||||
}
|
||||
|
||||
function onFilterValueObjChange(sel) {
|
||||
var wrap = sel.closest('.filterValueControl');
|
||||
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
|
||||
updateFilterValueFromDynamic(wrap);
|
||||
}
|
||||
|
||||
function onFilterValueFieldChange(sel) {
|
||||
updateFilterValueFromDynamic(sel.closest('.filterValueControl'));
|
||||
}
|
||||
|
||||
function onFilterValueVarChange(sel) {
|
||||
updateFilterValueFromVariable(sel.closest('.filterValueControl'));
|
||||
}
|
||||
|
||||
function initFilterValuePickers() {
|
||||
document.querySelectorAll('.filterValueControl').forEach(function (wrap) {
|
||||
var modeSel = wrap.querySelector('.filterValueMode');
|
||||
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var varPathInput = wrap.querySelector('.filterValueVarPath');
|
||||
var current = (wrap.dataset.currentValue || '').trim();
|
||||
var varMatch = _VAR_REF_RE.exec(current);
|
||||
var objMatch = _FILTER_REF_RE.exec(current);
|
||||
if (varMatch) {
|
||||
modeSel.value = 'variable';
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
wrap.querySelector('.filterValueVariable').style.display = 'block';
|
||||
varSel.value = varMatch[1].trim();
|
||||
if (varPathInput) varPathInput.value = (varMatch[2] || '').trim();
|
||||
} else if (objMatch) {
|
||||
modeSel.value = 'dynamique';
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
wrap.querySelector('.filterValueDynamique').style.display = 'block';
|
||||
objSel.value = objMatch[1].trim();
|
||||
populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), objMatch[2].trim());
|
||||
} else {
|
||||
modeSel.value = 'fixe';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
|
||||
// charge par pjax.js (navigation globale sans rechargement, voir
|
||||
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
|
||||
// script (voir runScriptsIn dans pjax.js), ce qui rappelle initBuilderPanel()
|
||||
// ci-dessous — donc le canevas et le panneau de propriétés se retrouvent à
|
||||
// jour automatiquement, sans qu'on ait besoin d'un second écouteur ici.
|
||||
initBuilderPanel();
|
||||
|
||||
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
|
||||
var GLOBAL_VARIABLES_OPTIONS_HTML = document.getElementById('nf-cond-variable') ? document.getElementById('nf-cond-variable').innerHTML : '';
|
||||
|
||||
function toggleActionFields(sel) {
|
||||
var form = sel.closest('.actionAddForm');
|
||||
var screenSel = form.querySelector('.targetScreenSelect');
|
||||
var elementFields = form.querySelector('.targetElementFields');
|
||||
var dataFields = form.querySelector('.targetDataFields');
|
||||
screenSel.style.display = sel.value === 'aller_a' ? 'block' : 'none';
|
||||
elementFields.style.display = sel.value === 'modifier_element' ? 'block' : 'none';
|
||||
dataFields.style.display = sel.value === 'modifier_donnee' ? 'block' : 'none';
|
||||
if (sel.value === 'modifier_element') {
|
||||
var propSel = elementFields.querySelector('.elementPropertySelect');
|
||||
toggleElementPropertyValue(propSel);
|
||||
}
|
||||
if (sel.value === 'modifier_donnee') {
|
||||
var defSel = dataFields.querySelector('.targetDefinitionSelect');
|
||||
if (defSel) onDataDefinitionChange(defSel);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.actionTypeSelect').forEach(toggleActionFields);
|
||||
|
||||
function toggleElementPropertyValue(sel) {
|
||||
var wrap = sel.closest('.targetElementFields');
|
||||
var visSel = wrap.querySelector('.elementValueVisibility');
|
||||
var colorInp = wrap.querySelector('.elementValueColor');
|
||||
var numInp = wrap.querySelector('.elementValueNumber');
|
||||
[visSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; el.removeAttribute('name'); });
|
||||
if (sel.value === 'visibilite') {
|
||||
visSel.style.display = 'block'; visSel.setAttribute('name', 'element_value');
|
||||
} else if (sel.value === 'bg_color' || sel.value === 'border_color') {
|
||||
colorInp.style.display = 'block'; colorInp.setAttribute('name', 'element_value');
|
||||
} else {
|
||||
numInp.style.display = 'block'; numInp.setAttribute('name', 'element_value');
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.elementPropertySelect').forEach(toggleElementPropertyValue);
|
||||
|
||||
function onDataDefinitionChange(sel) {
|
||||
var wrap = sel.closest('.targetDataFields');
|
||||
var info = DEFINITIONS_DATA[sel.value] || {rows: [], fields: []};
|
||||
var rowSel = wrap.querySelector('.targetRowSelect');
|
||||
var fieldSel = wrap.querySelector('.targetFieldSelect');
|
||||
if (!rowSel || !fieldSel) return;
|
||||
rowSel.innerHTML = info.rows.length
|
||||
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||||
fieldSel.innerHTML = info.fields.length
|
||||
? info.fields.map(function(f){ return '<option value="' + f.name + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||||
: '<option value="">Aucun champ simple</option>';
|
||||
}
|
||||
document.querySelectorAll('.targetDefinitionSelect').forEach(onDataDefinitionChange);
|
||||
|
||||
function syncAlign(radio) {
|
||||
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach(function(o){ o.classList.remove('active', 'is-primary'); });
|
||||
radio.closest('.alignOption').classList.add('active', 'is-primary');
|
||||
}
|
||||
|
||||
// Choix du format d'aperçu (Portrait/Paysage/Carré) : mémorisé côté serveur
|
||||
// (voir screen_set_aspect.py) pour ne pas repartir sur Portrait à chaque
|
||||
// ajout/suppression d'élément ou changement de sélection — ces actions
|
||||
// redemandent ce panneau au serveur, qui doit donc connaître le choix déjà
|
||||
// fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois
|
||||
// par (re)rendu de ce panneau.
|
||||
function bindAspectButtons() {
|
||||
document.querySelectorAll('.aspectBtn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.aspectBtn').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
// Dans l'éditeur (≥1300px), le canevas remplit tout .canvasFrame
|
||||
// (voir .canvas dans style.css) — cet aspect-ratio ne sert donc plus
|
||||
// qu'au repli en page empilée (<1300px, voir le media query) et à
|
||||
// l'aperçu jouable ("Jouer"), pas à la mise en page ici.
|
||||
document.getElementById('canvas').style.aspectRatio = btn.dataset.aspect;
|
||||
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/aspect", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspect: btn.dataset.aspect }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.uploadInput').forEach(function(input){
|
||||
input.addEventListener('change', function(){
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
data.append('file', input.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", {method: 'POST', body: data})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(json){
|
||||
if (json.url) {
|
||||
targetField.value = json.url;
|
||||
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
|
||||
// Enregistre tout de suite (comme un clic sur "Enregistrer") pour
|
||||
// qu'un fichier envoyé ne reste jamais "en attente" si on oublie
|
||||
// de cliquer sur le bouton ensuite — l'image doit apparaître dès
|
||||
// l'envoi, sans étape supplémentaire à retenir.
|
||||
var form = document.getElementById('propsForm');
|
||||
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||||
}
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function(){ status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// ---------- Onglets du centre : Écran / Logique de la scène / Timeline
|
||||
// d'animation — un seul visible à la fois, chacun occupant tout l'espace
|
||||
// disponible (remplace l'ancien panneau du bas rétractable/redimensionnable
|
||||
// à la souris : un onglet "prend toute la place" par nature, et en changer
|
||||
// équivaut à "fermer" celui qu'on quitte). ----------
|
||||
function switchBuilderTab(tab) {
|
||||
document.querySelectorAll('.builderTabBtn').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
|
||||
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDashCreate(tab) {
|
||||
var bar = document.getElementById('dashCreate-' + tab);
|
||||
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
|
||||
if (bar.style.display === 'flex') {
|
||||
var firstInput = bar.querySelector('input, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBlockEditRow(blockId) {
|
||||
document.getElementById('blockEditRow' + blockId).classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// ---------- Panneaux flottants : glisser-déposer + redimensionnement +
|
||||
// plein écran ---------- Générique, porté tel quel depuis game_dashboard.html
|
||||
// (mêmes gestes partout dans l'outil plutôt qu'une variante par panneau).
|
||||
function makeFloatPanelDraggable(panel, header) {
|
||||
var dragging = false, offsetX = 0, offsetY = 0;
|
||||
header.addEventListener('mousedown', function (e) {
|
||||
if (e.target.closest('button')) return;
|
||||
dragging = true;
|
||||
if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit();
|
||||
var rect = panel.getBoundingClientRect();
|
||||
panel.style.left = rect.left + 'px';
|
||||
panel.style.top = rect.top + 'px';
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.transform = 'none';
|
||||
offsetX = e.clientX - rect.left;
|
||||
offsetY = e.clientY - rect.top;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (!dragging) return;
|
||||
panel.style.left = (e.clientX - offsetX) + 'px';
|
||||
panel.style.top = (e.clientY - offsetY) + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', function () {
|
||||
dragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
});
|
||||
}
|
||||
function makeFloatPanelResizable(panel, handle) {
|
||||
var resizing = false, startX = 0, startY = 0, startW = 0, startH = 0;
|
||||
handle.addEventListener('mousedown', function (e) {
|
||||
resizing = true;
|
||||
if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit();
|
||||
var rect = panel.getBoundingClientRect();
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startW = rect.width; startH = rect.height;
|
||||
panel.style.width = startW + 'px';
|
||||
panel.style.maxWidth = 'none';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (!resizing) return;
|
||||
panel.style.width = Math.max(320, startW + (e.clientX - startX)) + 'px';
|
||||
panel.style.height = Math.max(200, startH + (e.clientY - startY)) + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', function () {
|
||||
resizing = false;
|
||||
document.body.style.userSelect = '';
|
||||
});
|
||||
}
|
||||
function makeFloatPanelFullscreenable(panel, toggleBtn) {
|
||||
var saved = null;
|
||||
function enter() {
|
||||
saved = {
|
||||
left: panel.style.left, top: panel.style.top, right: panel.style.right,
|
||||
width: panel.style.width, height: panel.style.height,
|
||||
maxWidth: panel.style.maxWidth, transform: panel.style.transform,
|
||||
};
|
||||
panel.classList.add('floatPanel--fullscreen');
|
||||
panel.style.left = '12px'; panel.style.top = '12px'; panel.style.right = '12px'; panel.style.bottom = '12px';
|
||||
panel.style.width = 'auto'; panel.style.height = 'auto'; panel.style.maxWidth = 'none'; panel.style.transform = 'none';
|
||||
toggleBtn.title = 'Quitter le plein écran';
|
||||
}
|
||||
function exit() {
|
||||
panel.classList.remove('floatPanel--fullscreen');
|
||||
panel.style.bottom = '';
|
||||
if (saved) {
|
||||
panel.style.left = saved.left; panel.style.top = saved.top; panel.style.right = saved.right;
|
||||
panel.style.width = saved.width; panel.style.height = saved.height;
|
||||
panel.style.maxWidth = saved.maxWidth; panel.style.transform = saved.transform;
|
||||
}
|
||||
toggleBtn.title = 'Plein écran';
|
||||
}
|
||||
toggleBtn.addEventListener('click', function () {
|
||||
if (panel.classList.contains('floatPanel--fullscreen')) exit(); else enter();
|
||||
});
|
||||
panel._fsToggle = { enter: enter, exit: exit };
|
||||
return panel._fsToggle;
|
||||
}
|
||||
|
||||
// ---------- Modale d'un bloc de logique (voir screens/flow/blocks/) : un
|
||||
// seul bloc ouvert à la fois, réutilise le même #nodeForm/#flowCanvas pour
|
||||
// n'importe quel bloc — voir currentBlockNodes()/currentBlockEdges(). ----------
|
||||
(function () {
|
||||
var panel = document.getElementById('logicBlockPanel');
|
||||
makeFloatPanelDraggable(panel, document.getElementById('logicBlockPanelHeader'));
|
||||
makeFloatPanelResizable(panel, document.getElementById('logicBlockPanelResizeHandle'));
|
||||
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
|
||||
window.openLogicBlockPanel = function (blockId) {
|
||||
CURRENT_BLOCK_ID = blockId;
|
||||
closeNodeForm();
|
||||
var block = FLOW_BLOCKS.find(function (b) { return b.id === blockId; });
|
||||
document.getElementById('logicBlockPanelTitle').textContent = (block && block.name) || '?';
|
||||
panel.classList.remove('hidden');
|
||||
panel._fsToggle.enter();
|
||||
renderFlow();
|
||||
requestAnimationFrame(drawFlowEdges);
|
||||
};
|
||||
window.closeLogicBlockPanel = function () {
|
||||
panel.classList.add('hidden');
|
||||
CURRENT_BLOCK_ID = null;
|
||||
};
|
||||
})();
|
||||
|
||||
// L'ouverture initiale ?tab=/?block=... (données Jinja : request.args)
|
||||
// reste dans le petit <script> inline en fin de screen_edit.html, après
|
||||
// ce fichier — voir le commentaire là-bas.
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
// Données injectées par Jinja (GAME_SLUG, SCREEN_ID, SCREEN_EDIT_URL,
|
||||
// CURRENT_SELECTED_ID, ELEMENT_ADD_URL...) : voir le petit <script> inline
|
||||
// resté dans screen_edit.html, en tête de page — ce fichier statique ne
|
||||
// passe jamais par le moteur de templates, voir static/js/play/ pour le
|
||||
// même patron déjà en place côté moteur de jeu.
|
||||
function saveGeometry(id, x, y, width, height) {
|
||||
return fetch("/game/" + GAME_SLUG + "/elements/" + id + "/geometry", {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({x: x, y: y, width: width, height: height})
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Menu contextuel (clic droit) de l'arborescence des éléments ----------
|
||||
//
|
||||
// Une seule action pour l'instant : dupliquer l'élément dans son conteneur
|
||||
// actuel (même parent, ou même écran si posé directement dessus) — voir
|
||||
// screens/elements/duplicate_element.py. La duplication passe par un vrai
|
||||
// <form method="post"> soumis dynamiquement : pjax.js intercepte tous les
|
||||
// formulaires internes, donc pas besoin de gérer le fetch/la redirection
|
||||
// nous-mêmes ici.
|
||||
var contextMenuElementId = null;
|
||||
|
||||
function showElementContextMenu(event, elementId) {
|
||||
event.preventDefault();
|
||||
contextMenuElementId = elementId;
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
menu.style.left = event.pageX + 'px';
|
||||
menu.style.top = event.pageY + 'px';
|
||||
menu.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
|
||||
function hideElementContextMenu() {
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
contextMenuElementId = null;
|
||||
}
|
||||
|
||||
function contextMenuDuplicate() {
|
||||
var id = contextMenuElementId;
|
||||
hideElementContextMenu();
|
||||
if (!id) return;
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '/game/' + GAME_SLUG + '/elements/' + id + '/duplicate';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
if (!window.__forgeContextMenuBound) {
|
||||
window.__forgeContextMenuBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
if (menu && menu.style.display === 'block' && !menu.contains(e.target)) hideElementContextMenu();
|
||||
});
|
||||
document.addEventListener('scroll', hideElementContextMenu, true);
|
||||
}
|
||||
|
||||
// ---------- Arborescence : replier/déplier un conteneur ----------
|
||||
//
|
||||
// Le triangle (.treeToggle) replie/déplie SANS toucher à la sélection : le
|
||||
// nom de l'élément reste un lien à part (.treeRowLink) qui, lui, sélectionne
|
||||
// toujours l'élément et affiche ses réglages — cliquer sur un conteneur
|
||||
// replié donne donc bien accès à ses propriétés, comme demandé. L'état
|
||||
// replié/déplié est mémorisé par élément dans localStorage pour survivre à
|
||||
// un changement de sélection ou un rechargement de l'éditeur.
|
||||
function treeCollapseKey(nodeId) {
|
||||
return 'forgeTreeCollapsed:' + GAME_SLUG + ':' + SCREEN_ID + ':' + nodeId;
|
||||
}
|
||||
|
||||
function restoreTreeCollapsedState() {
|
||||
document.querySelectorAll('.elementTree .treeNode').forEach(function (node) {
|
||||
var id = node.dataset.elementId;
|
||||
var collapsed = false;
|
||||
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
|
||||
node.classList.toggle('collapsed', collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Panneaux gauche/droite : flottants, déplaçables, fermables ----------
|
||||
//
|
||||
// Sortis du flux (position:fixed en CSS) pour ne jamais rétrécir le canevas
|
||||
// selon où on les pose — les fermer libère cette largeur pour une
|
||||
// prévisualisation plus fidèle à ce que "Jouer" affiche réellement (voir
|
||||
// bug remonté : des panneaux trop larges réduisaient le canevas au point de
|
||||
// casser la mise en page des onglets). Un seul jeu de fonctions génériques
|
||||
// (key = identifiant court mémorisé dans localStorage, distinct par
|
||||
// panneau) plutôt qu'une copie par panneau. Position et état ouvert/fermé
|
||||
// survivent à un changement de sélection (qui remplace tout #builder3, voir
|
||||
// refreshBuilderPanel) ou un rechargement — voir les appels à
|
||||
// restoreFloatPanelState() dans initBuilderPanel().
|
||||
function floatPanelStateKey(key) {
|
||||
return 'forgeFloatPanel:' + GAME_SLUG + ':' + SCREEN_ID + ':' + key;
|
||||
}
|
||||
|
||||
function saveFloatPanelState(key, state) {
|
||||
try {
|
||||
var current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
||||
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function loadFloatPanelState(key) {
|
||||
try { return JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}'); } catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function restoreFloatPanelState(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (!panel) return;
|
||||
var state = loadFloatPanelState(key);
|
||||
if (state.left != null && state.top != null) {
|
||||
panel.style.left = state.left + 'px';
|
||||
panel.style.top = state.top + 'px';
|
||||
panel.style.right = 'auto';
|
||||
}
|
||||
var open = state.open !== false; // ouvert par défaut tant que rien n'a été fermé
|
||||
panel.classList.toggle('hidden', !open);
|
||||
if (reopenBtn) reopenBtn.classList.toggle('visible', !open);
|
||||
}
|
||||
|
||||
function closeFloatPanel(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (panel) panel.classList.add('hidden');
|
||||
if (reopenBtn) reopenBtn.classList.add('visible');
|
||||
saveFloatPanelState(key, { open: false });
|
||||
}
|
||||
|
||||
function openFloatPanel(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
if (reopenBtn) reopenBtn.classList.remove('visible');
|
||||
saveFloatPanelState(key, { open: true });
|
||||
}
|
||||
|
||||
// Raccourcis utilisés directement par les attributs onclick du gabarit.
|
||||
function closePropsPanel() { closeFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
|
||||
function openPropsPanel() { openFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
|
||||
function closeLeftPanel() { closeFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
|
||||
function openLeftPanel() { openFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
|
||||
|
||||
if (!window.__forgeFloatPanelDragBound) {
|
||||
window.__forgeFloatPanelDragBound = true;
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
var handle = e.target.closest('.floatPanelHeader');
|
||||
if (!handle || e.target.closest('button')) return;
|
||||
var panel = document.getElementById(handle.dataset.dragPanel);
|
||||
var key = handle.dataset.dragKey;
|
||||
if (!panel) return;
|
||||
var rect = panel.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = rect.left, startTop = rect.top;
|
||||
panel.style.right = 'auto';
|
||||
function onMove(ev) {
|
||||
var maxLeft = window.innerWidth - panel.offsetWidth - 4;
|
||||
var maxTop = window.innerHeight - 40;
|
||||
var left = Math.max(4, Math.min(maxLeft, startLeft + (ev.clientX - startX)));
|
||||
var top = Math.max(4, Math.min(maxTop, startTop + (ev.clientY - startY)));
|
||||
panel.style.left = left + 'px';
|
||||
panel.style.top = top + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
saveFloatPanelState(key, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) });
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Galerie d'icônes Font Awesome (panneau de propriétés) ----------
|
||||
//
|
||||
// Cliquer une tuile l'ajoute directement (comme n'importe quelle tuile de
|
||||
// "Ajouter un élément sur l'écran") ; la glisser jusque sur le canevas fait
|
||||
// la même chose au relâchement — le drag HTML5 natif ne permet pas de créer
|
||||
// un vrai élément positionné exactement sous le curseur sans aller-retour
|
||||
// serveur supplémentaire, donc on se contente ici de déclencher le même
|
||||
// ajout "à la position par défaut" que le clic, l'utilisateur repositionne
|
||||
// ensuite comme pour tout nouvel élément.
|
||||
function addIconElement(iconClass) {
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = ELEMENT_ADD_URL;
|
||||
form.innerHTML =
|
||||
'<input type="hidden" name="widget" value="icone">' +
|
||||
'<input type="hidden" name="icon_class" value="' + iconClass.replace(/"/g, '"') + '">';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
function filterIconGallery(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) {
|
||||
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
|
||||
tile.classList.toggle('iconHidden', !match);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Sélecteur visuel d'icône (réglage "Icône" d'un élément déjà
|
||||
// posé) — même liste que la galerie d'ajout ci-dessus, mais choisir une
|
||||
// icône ici remplace celle de l'élément SÉLECTIONNÉ (champ caché
|
||||
// "field-icon_class") au lieu d'en créer un nouveau. ----------
|
||||
// ---------- Confirmation de suppression d'un élément (modale custom, pas
|
||||
// le confirm() natif du navigateur) — voir deleteConfirmModal plus haut.
|
||||
// Interroge element_delete_impact.py pour savoir si des nœuds de la
|
||||
// Logique de la scène seraient aussi supprimés, et l'affiche si oui,
|
||||
// avant que l'utilisateur confirme quoi que ce soit. ----------
|
||||
var _pendingDeleteForm = null;
|
||||
|
||||
function handleDeleteElementSubmit(event, form) {
|
||||
if (form.dataset.confirmed === '1') {
|
||||
// Deuxième passage, après clic sur "Supprimer" dans la modale (voir
|
||||
// confirmDeleteElement()) : laisse cette fois la soumission suivre son
|
||||
// cours normalement (pjax.js l'intercepte comme n'importe quel autre
|
||||
// formulaire, voir static/pjax.js).
|
||||
form.dataset.confirmed = '';
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
_pendingDeleteForm = form;
|
||||
document.getElementById('deleteConfirmMessage').textContent = form.dataset.confirmMessage || 'Supprimer cet élément ?';
|
||||
var warnEl = document.getElementById('deleteConfirmFlowWarning');
|
||||
warnEl.style.display = 'none';
|
||||
warnEl.textContent = '';
|
||||
document.getElementById('deleteConfirmModal').classList.add('is-active');
|
||||
var m = form.getAttribute('action').match(/\/elements\/(\d+)\/delete$/);
|
||||
if (m) {
|
||||
fetch("/game/" + GAME_SLUG + "/elements/" + m[1] + "/delete-impact")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.flow_node_count) return;
|
||||
var n = data.flow_node_count;
|
||||
var plural = n > 1 ? 's' : '';
|
||||
warnEl.textContent = '⚠️ ' + n + ' connexion' + plural + ' de la Logique de la scène (déclencheur/action) utilise' +
|
||||
(n > 1 ? 'nt' : '') + ' cet élément et sera' + (n > 1 ? 'ont' : '') + ' aussi supprimée' + plural + '.';
|
||||
warnEl.style.display = 'block';
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function closeDeleteConfirmModal() {
|
||||
var modal = document.getElementById('deleteConfirmModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
_pendingDeleteForm = null;
|
||||
}
|
||||
|
||||
function confirmDeleteElement() {
|
||||
if (!_pendingDeleteForm) return;
|
||||
var form = _pendingDeleteForm;
|
||||
_pendingDeleteForm = null;
|
||||
closeDeleteConfirmModal();
|
||||
form.dataset.confirmed = '1';
|
||||
if (form.requestSubmit) form.requestSubmit(); else form.submit();
|
||||
}
|
||||
|
||||
function openIconPicker() {
|
||||
var modal = document.getElementById('iconPickerModal');
|
||||
if (modal) modal.classList.add('is-active');
|
||||
}
|
||||
|
||||
function closeIconPicker() {
|
||||
var modal = document.getElementById('iconPickerModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function chooseIcon(iconSlug) {
|
||||
var input = document.getElementById('field-icon_class');
|
||||
var preview = document.getElementById('iconPickerPreview');
|
||||
if (input) input.value = iconSlug;
|
||||
if (preview) preview.style.setProperty('--icon-url', "url('/static/icons/" + iconSlug + ".svg')");
|
||||
closeIconPicker();
|
||||
submitPropsForm();
|
||||
}
|
||||
|
||||
function filterIconPickerModal(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconPickerGrid .iconGalleryTile').forEach(function (tile) {
|
||||
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
|
||||
tile.classList.toggle('iconHidden', !match);
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeIconPickerEscBound) {
|
||||
window.__forgeIconPickerEscBound = true;
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeIconPicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeIconGalleryDragBound) {
|
||||
window.__forgeIconGalleryDragBound = true;
|
||||
var draggedIconClass = null;
|
||||
|
||||
document.addEventListener('dragstart', function (e) {
|
||||
var tile = e.target.closest('.iconGalleryTile');
|
||||
if (!tile) return;
|
||||
draggedIconClass = tile.dataset.iconClass;
|
||||
tile.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function (e) {
|
||||
var tile = e.target.closest('.iconGalleryTile');
|
||||
if (tile) tile.classList.remove('dragging');
|
||||
draggedIconClass = null;
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', function (e) {
|
||||
if (!draggedIconClass || !e.target.closest('#canvas')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function (e) {
|
||||
if (!draggedIconClass || !e.target.closest('#canvas')) return;
|
||||
e.preventDefault();
|
||||
addIconElement(draggedIconClass);
|
||||
draggedIconClass = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Surbrillance sur le canevas de l'élément sélectionné ----------
|
||||
//
|
||||
// Un élément posé DIRECTEMENT sur l'écran a déjà son cadre de sélection via
|
||||
// la classe "canvasElement.selected" (posée côté serveur). Un élément
|
||||
// imbriqué dans un conteneur/répéteur/groupe de champs n'a pas ce cadre
|
||||
// séparé — sa propre balise porte "data-element-id" (voir visible_attrs.py)
|
||||
// mais rien ne la mettait en valeur, donc sélectionner un enfant dans
|
||||
// l'arborescence ne se voyait pas du tout sur le canevas. Ce contour
|
||||
// générique cible directement cette balise, quelle que soit sa profondeur.
|
||||
function applySelectionHighlight(id) {
|
||||
var canvas = document.getElementById('canvas');
|
||||
if (!canvas) return;
|
||||
canvas.querySelectorAll('.elementHighlighted').forEach(function (n) {
|
||||
n.classList.remove('elementHighlighted');
|
||||
});
|
||||
if (!id) return;
|
||||
var node = canvas.querySelector('[data-element-id="' + id + '"]');
|
||||
if (node) node.classList.add('elementHighlighted');
|
||||
}
|
||||
|
||||
// ---------- Élément à onglets : bascule d'onglet côté client ----------
|
||||
//
|
||||
// Un seul panneau visible à la fois (voir render_onglets.py) — basculé ici
|
||||
// plutôt que par un aller-retour serveur, pour un rendu instantané aussi
|
||||
// bien dans l'éditeur qu'en jeu (voir la même fonction dans play.html).
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeTreeToggleBound) {
|
||||
window.__forgeTreeToggleBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.treeToggle');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var node = btn.closest('.treeNode');
|
||||
var collapsed = node.classList.toggle('collapsed');
|
||||
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {}
|
||||
});
|
||||
}
|
||||
|
||||
restoreTreeCollapsedState();
|
||||
|
||||
// ---------- Arborescence : réordonner ou déplacer dans un conteneur par
|
||||
// glisser-déposer ----------
|
||||
//
|
||||
// Deux gestes distincts, selon où on relâche sur une ligne cible :
|
||||
// - au-dessus/en dessous d'une ligne DU MÊME CONTENEUR (même parent, ou
|
||||
// toutes deux à la racine) : change leur ordre (voir reorder_element.py).
|
||||
// - sur le MILIEU d'une ligne dont le widget est un conteneur (n'importe où
|
||||
// dans l'arborescence, même un autre parent) : pose l'élément DEDANS, en
|
||||
// dernier enfant (voir move_element_to_container.py) — c'est désormais LA
|
||||
// façon de ranger un élément dans un conteneur (l'ancien menu dédié
|
||||
// "Ajouter DANS ce conteneur" du panneau de propriétés a été retiré,
|
||||
// devenu redondant avec ce geste).
|
||||
// Écouteurs posés une seule fois sur document (délégation) : ils survivent
|
||||
// au remplacement de #builder3 par refreshBuilderPanel/refreshCanvasOnly,
|
||||
// pas besoin de les rebrancher.
|
||||
var treeDragElementId = null;
|
||||
var treeDragParentId = null;
|
||||
|
||||
function treeRowDropInfo(row) {
|
||||
var node = row.closest('.treeNode');
|
||||
return {
|
||||
elementId: node ? node.dataset.elementId : null,
|
||||
parentId: row.dataset.parentId || '',
|
||||
isContainer: row.dataset.isContainer === '1',
|
||||
};
|
||||
}
|
||||
|
||||
function clearTreeDropIndicators() {
|
||||
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach(function (r) {
|
||||
r.classList.remove('dropBefore', 'dropAfter', 'dropInside');
|
||||
});
|
||||
}
|
||||
|
||||
// Zone touchée par le curseur dans la ligne cible : avec les frères et
|
||||
// sœurs, avant/après seulement (moitié haute/basse) ; sur un conteneur,
|
||||
// une bande centrale "dedans" s'ajoute (tiers haut/milieu/bas) — sauf si ce
|
||||
// conteneur n'est PAS un frère/sœur du même parent, où seul "dedans" a un
|
||||
// sens (il n'y a pas d'ordre à changer avec un élément d'un autre parent).
|
||||
function treeDropZone(row, clientY, sameParent, isContainer) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
var ratio = (clientY - rect.top) / rect.height;
|
||||
if (!sameParent) return isContainer ? 'inside' : null;
|
||||
if (!isContainer) return ratio < 0.5 ? 'before' : 'after';
|
||||
if (ratio < 1 / 3) return 'before';
|
||||
if (ratio > 2 / 3) return 'after';
|
||||
return 'inside';
|
||||
}
|
||||
|
||||
if (!window.__forgeTreeDragBound) {
|
||||
window.__forgeTreeDragBound = true;
|
||||
|
||||
document.addEventListener('dragstart', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
if (!row) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
treeDragElementId = info.elementId;
|
||||
treeDragParentId = info.parentId;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
if (row) row.classList.remove('dragging');
|
||||
clearTreeDropIndicators();
|
||||
treeDragElementId = null;
|
||||
treeDragParentId = null;
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
if (!row || treeDragElementId == null) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
|
||||
var sameParent = info.parentId === treeDragParentId;
|
||||
var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
|
||||
if (!zone) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
clearTreeDropIndicators();
|
||||
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
clearTreeDropIndicators();
|
||||
if (!row || treeDragElementId == null) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
|
||||
var sameParent = info.parentId === treeDragParentId;
|
||||
var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
|
||||
if (!zone) return;
|
||||
e.preventDefault();
|
||||
var draggedId = treeDragElementId;
|
||||
if (zone === 'inside') {
|
||||
fetch('/game/' + GAME_SLUG + '/elements/' + draggedId + '/move-to-container', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parent_id: info.elementId })
|
||||
}).then(refreshAfterReorder);
|
||||
} else {
|
||||
fetch('/game/' + GAME_SLUG + '/elements/' + draggedId + '/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_id: info.elementId, position: zone })
|
||||
}).then(refreshAfterReorder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAfterReorder() {
|
||||
var url = SCREEN_EDIT_URL + (CURRENT_SELECTED_ID ? ('?selected=' + CURRENT_SELECTED_ID) : '');
|
||||
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var newCanvas = doc.getElementById('canvas');
|
||||
var curCanvas = document.getElementById('canvas');
|
||||
if (newCanvas && curCanvas) {
|
||||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
}
|
||||
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
var curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; }
|
||||
restoreTreeCollapsedState();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
|
||||
//
|
||||
// Se déplacer dans le canevas (glisser, redimensionner, cliquer un autre
|
||||
// élément) et modifier les réglages d'un élément ne doivent jamais recharger
|
||||
// la page : on va chercher en arrière-plan (fetch) uniquement le fragment
|
||||
// HTML dont on a besoin (le bloc #builder3 en entier pour changer de
|
||||
// sélection, ou juste #canvas pour refléter un changement visuel après une
|
||||
// sauvegarde automatique) et on le remplace dans le DOM courant. L'URL
|
||||
// (?selected=...) reste mise à jour via l'historique (pushState), pour que
|
||||
// le bouton "précédent" du navigateur continue à fonctionner normalement.
|
||||
|
||||
function refreshBuilderPanel(selectedId) {
|
||||
var url = SCREEN_EDIT_URL + "?selected=" + selectedId;
|
||||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||||
.then(function (r) { return r.text().then(function (html) { return { html: html, url: r.url || url }; }); })
|
||||
.then(function (result) {
|
||||
var doc = new DOMParser().parseFromString(result.html, "text/html");
|
||||
var newBuilder = doc.getElementById("builder3");
|
||||
var curBuilder = document.getElementById("builder3");
|
||||
if (newBuilder && curBuilder) {
|
||||
curBuilder.innerHTML = newBuilder.innerHTML;
|
||||
}
|
||||
CURRENT_SELECTED_ID = selectedId;
|
||||
history.pushState({ pjax: true }, "", result.url);
|
||||
initBuilderPanel();
|
||||
})
|
||||
.catch(function () {
|
||||
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
||||
// laisser l'éditeur dans un état incohérent.
|
||||
window.location.href = url;
|
||||
});
|
||||
}
|
||||
|
||||
function refreshCanvasOnly() {
|
||||
if (!CURRENT_SELECTED_ID) return Promise.resolve();
|
||||
var url = SCREEN_EDIT_URL + "?selected=" + CURRENT_SELECTED_ID;
|
||||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
var newCanvas = doc.getElementById("canvas");
|
||||
var curCanvas = document.getElementById("canvas");
|
||||
if (newCanvas && curCanvas) {
|
||||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
}
|
||||
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
||||
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
||||
// focus/curseur pendant qu'on modifie un réglage.
|
||||
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
var curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; restoreTreeCollapsedState(); }
|
||||
});
|
||||
}
|
||||
|
||||
function goToSelected(id) {
|
||||
return refreshBuilderPanel(id);
|
||||
}
|
||||
|
||||
// ---------- Glisser-déposer / redimensionner sur le canevas ----------
|
||||
//
|
||||
// Un mousedown qui bouge de plus de quelques pixels déplace l'élément
|
||||
// (position sauvegardée en arrière-plan à la fin, sans recharger la page —
|
||||
// comme l'élément déplacé reste celui déjà sélectionné, on se contente de
|
||||
// mettre à jour les champs X/Y/Largeur/Hauteur en local, rien d'autre à
|
||||
// rafraîchir) ; un mousedown qui ne bouge pas est traité comme un simple
|
||||
// clic de sélection (qui, lui, doit rafraîchir le panneau de droite pour
|
||||
// afficher les réglages du nouvel élément choisi).
|
||||
function onElementMouseDown(e, id) {
|
||||
if (e.target.classList.contains('resizeHandle')) return;
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = parseFloat(el.style.left), startTop = parseFloat(el.style.top);
|
||||
var width = parseFloat(el.style.width), height = parseFloat(el.style.height);
|
||||
var moved = false;
|
||||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
||||
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
||||
// devoir repasser par l'arborescence à chaque fois. Le glisser-déposer,
|
||||
// lui, reste toujours celui de la boîte de plus haut niveau (id).
|
||||
var clickedTarget = e.target.closest('[data-element-id]');
|
||||
var clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
|
||||
var newLeft = Math.max(0, Math.min(100 - width, startLeft + (dx / rect.width) * 100));
|
||||
var newTop = Math.max(0, Math.min(100 - height, startTop + (dy / rect.height) * 100));
|
||||
el.style.left = newLeft + '%';
|
||||
el.style.top = newTop + '%';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
if (moved) {
|
||||
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
|
||||
updatePosFields(newLeft, newTop, width, height);
|
||||
saveGeometry(id, newLeft, newTop, width, height);
|
||||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||||
goToSelected(clickedId);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function onResizeMouseDown(e, id) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startWidth = parseFloat(el.style.width), startHeight = parseFloat(el.style.height);
|
||||
var left = parseFloat(el.style.left), top = parseFloat(el.style.top);
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
var newWidth = Math.max(5, Math.min(100 - left, startWidth + (dx / rect.width) * 100));
|
||||
var newHeight = Math.max(5, Math.min(100 - top, startHeight + (dy / rect.height) * 100));
|
||||
el.style.width = newWidth + '%';
|
||||
el.style.height = newHeight + '%';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
|
||||
updatePosFields(left, top, newWidth, newHeight);
|
||||
saveGeometry(id, left, top, newWidth, newHeight);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function updatePosFields(x, y, w, h) {
|
||||
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
|
||||
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
|
||||
if (xEl) xEl.value = Math.round(x * 10) / 10;
|
||||
if (yEl) yEl.value = Math.round(y * 10) / 10;
|
||||
if (wEl) wEl.value = Math.round(w * 10) / 10;
|
||||
if (hEl) hEl.value = Math.round(h * 10) / 10;
|
||||
}
|
||||
|
||||
// ---------- Panneau de propriétés : tout est enregistré automatiquement ----------
|
||||
//
|
||||
// Il n'y a plus de bouton "Enregistrer" : chaque réglage se sauvegarde tout
|
||||
// seul dès qu'on le change (immédiatement pour une case à cocher/couleur/
|
||||
// liste déroulante, après une courte pause pour un champ texte ou un
|
||||
// slider qu'on est en train de glisser, pour éviter d'envoyer une requête à
|
||||
// chaque caractère tapé ou à chaque pixel de glissement).
|
||||
var autosaveTimer = null;
|
||||
var autosaveIndicator = null;
|
||||
|
||||
function flashAutosave(state) {
|
||||
if (!autosaveIndicator) return;
|
||||
autosaveIndicator.classList.remove('saving', 'saved');
|
||||
if (state === 'saving') {
|
||||
autosaveIndicator.textContent = 'Enregistrement...';
|
||||
autosaveIndicator.classList.add('saving');
|
||||
} else {
|
||||
autosaveIndicator.textContent = 'Enregistré automatiquement ✓';
|
||||
autosaveIndicator.classList.add('saved');
|
||||
}
|
||||
}
|
||||
|
||||
function submitPropsForm() {
|
||||
var form = document.getElementById('propsForm');
|
||||
if (!form) return Promise.resolve();
|
||||
flashAutosave('saving');
|
||||
var data = new FormData(form);
|
||||
return fetch(form.getAttribute('action'), {
|
||||
method: 'POST', body: data, headers: { "X-Requested-With": "pjax" }
|
||||
}).then(function () {
|
||||
flashAutosave('saved');
|
||||
return refreshCanvasOnly();
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedSubmitPropsForm() {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(submitPropsForm, 500);
|
||||
}
|
||||
|
||||
// Insère "{{champ}}" à l'emplacement du curseur dans le champ "Contenu"
|
||||
// (voir controls_with_values.py, control["field_options"] posé une fois
|
||||
// "Lier à un objet de données" réglé) — évite d'avoir à taper la syntaxe
|
||||
// à la main sans savoir quels noms de champs existent. OPEN/CLOSE en deux
|
||||
// morceaux : Jinja interpréterait "{{"/"}}" littéralement adjacents dans
|
||||
// le SOURCE de ce template comme le début d'une expression s'il les
|
||||
// rencontrait tels quels (même à l'intérieur d'un <script>), voir la même
|
||||
// astuce pour _FILTER_REF_OPEN/_FILTER_REF_CLOSE plus bas.
|
||||
function insertFieldAtCursor(fieldId, pickerId) {
|
||||
var field = document.getElementById(fieldId);
|
||||
var picker = document.getElementById(pickerId);
|
||||
if (!field || !picker || !picker.value) return;
|
||||
var open = '{' + '{', close = '}' + '}';
|
||||
var text = open + picker.value + close;
|
||||
var start = field.selectionStart || 0, end = field.selectionEnd || 0;
|
||||
field.value = field.value.substring(0, start) + text + field.value.substring(end);
|
||||
var newPos = start + text.length;
|
||||
field.focus();
|
||||
field.setSelectionRange(newPos, newPos);
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
picker.value = '';
|
||||
}
|
||||
|
||||
function bindPropsAutosave() {
|
||||
var form = document.getElementById('propsForm');
|
||||
autosaveIndicator = document.getElementById('autosaveStatus');
|
||||
if (!form) return;
|
||||
form.querySelectorAll('input, textarea, select').forEach(function (field) {
|
||||
if (field.classList.contains('uploadInput')) return; // géré séparément (upload de fichier)
|
||||
var debounced = field.type === 'text' || field.type === 'range' || field.tagName === 'TEXTAREA';
|
||||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||||
// Le bascule visuelle de l'alignement (case active) est déjà gérée
|
||||
// par l'attribut onchange="syncAlign(this)" posé sur chaque radio.
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -404,6 +404,11 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55);
|
||||
}
|
||||
.floatPanel.hidden{ display:none; }
|
||||
/* Utilitaire générique (jusqu'ici .hidden n'existait que scopé à
|
||||
.floatPanel/.columnFilterMenu — ex. la ligne d'édition d'un bloc de
|
||||
logique dans screen_edit.html, un <tr>, avait besoin d'un .hidden qui
|
||||
marche seul, sans classe compagne). */
|
||||
.hidden{ display:none; }
|
||||
/* Coin de redimensionnement (voir wireFloatPanelResize() dans
|
||||
game_dashboard.html) — un panneau devient redimensionnable simplement en
|
||||
lui ajoutant ce coin, JS mis à part rien d'autre à faire côté CSS. */
|
||||
@@ -666,6 +671,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
||||
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
||||
.objectPanelTabPanel.active{ display:flex; }
|
||||
|
||||
/* Modale d'un bloc de logique (voir openLogicBlockPanel() dans
|
||||
screen_edit.html) : le .floatPanelBody générique n'est pas un conteneur
|
||||
flex par défaut — cette classe l'y transforme pour que .nodeForm/
|
||||
.flowCanvasWrap gardent le même comportement (barre d'outils fixe,
|
||||
canevas qui prend tout l'espace restant) qu'avant leur déménagement
|
||||
hors de l'ancien onglet "Logique de la scène". */
|
||||
.logicBlockPanelBody{ display:flex; flex-direction:column; height:100%; }
|
||||
|
||||
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
|
||||
|
||||
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
|
||||
|
||||
@@ -212,6 +212,11 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.55);
|
||||
}
|
||||
.floatPanel.hidden{ display:none; }
|
||||
/* Utilitaire générique (jusqu'ici .hidden n'existait que scopé à
|
||||
.floatPanel/.columnFilterMenu — ex. la ligne d'édition d'un bloc de
|
||||
logique dans screen_edit.html, un <tr>, avait besoin d'un .hidden qui
|
||||
marche seul, sans classe compagne). */
|
||||
.hidden{ display:none; }
|
||||
/* Coin de redimensionnement (voir wireFloatPanelResize() dans
|
||||
game_dashboard.html) — un panneau devient redimensionnable simplement en
|
||||
lui ajoutant ce coin, JS mis à part rien d'autre à faire côté CSS. */
|
||||
@@ -474,6 +479,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
||||
.objectPanelTabPanel{ display:none; flex-direction:column; }
|
||||
.objectPanelTabPanel.active{ display:flex; }
|
||||
|
||||
/* Modale d'un bloc de logique (voir openLogicBlockPanel() dans
|
||||
screen_edit.html) : le .floatPanelBody générique n'est pas un conteneur
|
||||
flex par défaut — cette classe l'y transforme pour que .nodeForm/
|
||||
.flowCanvasWrap gardent le même comportement (barre d'outils fixe,
|
||||
canevas qui prend tout l'espace restant) qu'avant leur déménagement
|
||||
hors de l'ancien onglet "Logique de la scène". */
|
||||
.logicBlockPanelBody{ display:flex; flex-direction:column; height:100%; }
|
||||
|
||||
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
|
||||
|
||||
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
|
||||
|
||||
+19
-973
File diff suppressed because it is too large
Load Diff
+98
-2118
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,38 @@ def game(client):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js")
|
||||
_PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play")
|
||||
_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit")
|
||||
|
||||
|
||||
def _js_bundle(directory):
|
||||
parts = []
|
||||
for name in sorted(os.listdir(directory)):
|
||||
path = os.path.join(directory, name)
|
||||
if os.path.isfile(path) and name.endswith(".js"):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
parts.append(f.read())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def play_js_bundle():
|
||||
"""Concatène tout le JS du moteur jouable (voir le plan de
|
||||
modularisation — static/js/play/, un fichier par responsabilité,
|
||||
extrait de l'ancien <script> unique de templates/play.html). Plusieurs
|
||||
tests vérifient que "le JS livré au joueur" contient telle fonction/
|
||||
telle chaîne, sans dépendre d'un navigateur — avant le découpage, ça
|
||||
revenait à chercher dans le HTML de /game/<slug>/play (tout était
|
||||
inline) ; maintenant la logique vit dans ces fichiers statiques."""
|
||||
return _js_bundle(_PLAY_JS_DIR)
|
||||
|
||||
|
||||
def screen_edit_js_bundle():
|
||||
"""Même principe que play_js_bundle(), pour l'éditeur (static/js/
|
||||
screen_edit/, extrait de templates/screen_edit.html)."""
|
||||
return _js_bundle(_SCREEN_EDIT_JS_DIR)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mail_object(client, game):
|
||||
"""Un objet "Mail" avec deux champs texte (sujet, expediteur), pour les
|
||||
|
||||
@@ -4,6 +4,8 @@ et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -98,8 +100,9 @@ def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "animate.min.css" in html
|
||||
assert "runAnimationTimeline" in html
|
||||
assert "injectCustomKeyframes" in html
|
||||
js = play_js_bundle()
|
||||
assert "runAnimationTimeline" in js
|
||||
assert "injectCustomKeyframes" in js
|
||||
|
||||
|
||||
def test_play_page_payload_includes_clip(client, game):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests unitaires de compute_new_value (screens/data_actions/compute_operation.py)
|
||||
— logique partagée par apply_data_action.py (champ d'objet) et
|
||||
apply_variable_action.py (variable globale), factorisée pour ne plus être
|
||||
dupliquée dans les deux fichiers avant que la Phase 2 (hasard/opérations
|
||||
mathématiques) n'y ajoute 6 nouvelles opérations. Verrouille le
|
||||
comportement des 7 opérations existantes."""
|
||||
import pytest
|
||||
|
||||
from screens.data_actions.compute_operation import compute_new_value
|
||||
|
||||
|
||||
def test_definir_texte():
|
||||
assert compute_new_value("definir_texte", "ancien", "nouveau", False) == "nouveau"
|
||||
assert compute_new_value("definir_texte", "ancien", "", False) == ""
|
||||
assert compute_new_value("definir_texte", "ancien", None, False) == ""
|
||||
|
||||
|
||||
def test_definir_nombre_entier_et_decimal():
|
||||
assert compute_new_value("definir_nombre", 0, "5", False) == 5
|
||||
assert isinstance(compute_new_value("definir_nombre", 0, "5", False), int)
|
||||
assert compute_new_value("definir_nombre", 0, "5.7", True) == 5.7
|
||||
assert compute_new_value("definir_nombre", 0, "pas un nombre", False) == 0
|
||||
|
||||
|
||||
def test_definir_bool():
|
||||
assert compute_new_value("definir_bool_vrai", 0, None, False) == 1
|
||||
assert compute_new_value("definir_bool_faux", 1, None, False) == 0
|
||||
|
||||
|
||||
def test_basculer_bool():
|
||||
assert compute_new_value("basculer_bool", 0, None, False) == 1
|
||||
assert compute_new_value("basculer_bool", 1, None, False) == 0
|
||||
assert compute_new_value("basculer_bool", None, None, False) == 1
|
||||
|
||||
|
||||
def test_incrementer_decrementer_entier():
|
||||
assert compute_new_value("incrementer", 10, "5", False) == 15
|
||||
assert compute_new_value("decrementer", 10, "5", False) == 5
|
||||
assert compute_new_value("incrementer", None, "5", False) == 5
|
||||
|
||||
|
||||
def test_incrementer_decimal_garde_les_decimales():
|
||||
result = compute_new_value("incrementer", 10.0, "0.5", True)
|
||||
assert result == 10.5
|
||||
assert isinstance(result, float)
|
||||
|
||||
|
||||
def test_incrementer_valeur_invalide_ne_change_rien():
|
||||
assert compute_new_value("incrementer", 10, "pas un nombre", False) == 10
|
||||
|
||||
|
||||
def test_operation_inconnue_leve_value_error():
|
||||
with pytest.raises(ValueError):
|
||||
compute_new_value("multiplier", 10, "2", False)
|
||||
@@ -13,6 +13,8 @@ passent."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import screen_edit_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -88,7 +90,7 @@ def test_screen_edit_panel_exposes_variable_source_condition_ui(client, game):
|
||||
assert 'id="nf-cond-source"' in html
|
||||
assert 'id="nf-cond-variable-fields"' in html
|
||||
assert "onFlowConditionSourceChange" in html
|
||||
assert "onFlowClauseSourceChange" in html
|
||||
assert "onFlowClauseSourceChange" in screen_edit_js_bundle()
|
||||
|
||||
|
||||
def test_condition_builder_still_available_without_any_object_defined(client, game):
|
||||
|
||||
@@ -10,6 +10,8 @@ vérifient les tests de non-régression ci-dessous, en plus des tests dédiés
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -49,10 +51,9 @@ def test_legacy_single_clause_condition_still_works_without_cond_clauses(client,
|
||||
)
|
||||
node = resp.get_json()
|
||||
assert node["cond_clauses"] is None
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "evaluateConditionClause" in html
|
||||
assert "cond_clauses" in html
|
||||
js = play_js_bundle()
|
||||
assert "evaluateConditionClause" in js
|
||||
assert "cond_clauses" in js
|
||||
|
||||
|
||||
def test_condition_node_persists_clauses_and_combinator(client, game):
|
||||
@@ -102,7 +103,6 @@ def test_condition_combinator_defaults_to_et_when_absent(client, game):
|
||||
|
||||
|
||||
def test_play_page_exposes_combinator_logic(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "cond_combinator" in html
|
||||
assert "'ou'" in html
|
||||
js = play_js_bundle()
|
||||
assert "cond_combinator" in js
|
||||
assert "'ou'" in js
|
||||
|
||||
+12
-14
@@ -5,6 +5,8 @@ d'un élément après décision."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -72,11 +74,10 @@ def test_survol_trigger_node_persists(client, game):
|
||||
def test_play_page_exposes_hover_trigger_runtime(client, game):
|
||||
"""bindHoverTriggers() (mouseenter/mouseleave -> runFlowFrom) doit être
|
||||
exposé et appelé, exactement comme bindClicks() pour "Au clic"."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "bindHoverTriggers" in html
|
||||
assert "'survol'" in html
|
||||
assert "'fin_survol'" in html
|
||||
js = play_js_bundle()
|
||||
assert "bindHoverTriggers" in js
|
||||
assert "'survol'" in js
|
||||
assert "'fin_survol'" in js
|
||||
|
||||
|
||||
def test_modifier_element_contenu_action_persists(client, game):
|
||||
@@ -107,9 +108,7 @@ def test_modifier_element_contenu_action_persists(client, game):
|
||||
|
||||
|
||||
def test_play_page_exposes_contenu_property_runtime(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'contenu'" in html
|
||||
assert "'contenu'" in play_js_bundle()
|
||||
|
||||
|
||||
# ---------- 3.2 — Séquences temporisées ----------
|
||||
@@ -128,10 +127,9 @@ def test_attendre_action_node_persists_delay(client, game):
|
||||
|
||||
|
||||
def test_play_page_exposes_wait_action_runtime(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'attendre'" in html
|
||||
assert "setTimeout" in html
|
||||
js = play_js_bundle()
|
||||
assert "'attendre'" in js
|
||||
assert "setTimeout" in js
|
||||
|
||||
|
||||
# ---------- 3.3 — Surbrillance générique dynamique ----------
|
||||
@@ -157,7 +155,7 @@ def test_play_page_exposes_highlight_runtime_and_css(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "forgeHighlight" in html
|
||||
assert "'surbrillance'" in html
|
||||
assert "'surbrillance'" in play_js_bundle()
|
||||
|
||||
|
||||
# ---------- 3.4 — Overlay / modale réutilisable ----------
|
||||
@@ -473,4 +471,4 @@ def test_play_page_exposes_lock_runtime_and_css(client, game):
|
||||
html = resp.data.decode()
|
||||
assert "forgeDisabled" in html
|
||||
assert "pointer-events:none" in html
|
||||
assert "'desactive'" in html
|
||||
assert "'desactive'" in play_js_bundle()
|
||||
|
||||
+9
-9
@@ -2,6 +2,8 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -143,11 +145,10 @@ def test_play_page_exposes_row_bindings_helpers(client, game):
|
||||
résoudre les {{champ}} de la ligne ouverte (voir README, section
|
||||
"Cliquer une ligne de Répéteur") — vérifie que le JS livré au joueur
|
||||
contient bien les briques attendues, sans dépendre d'un navigateur."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "applyOpenRowBindings" in html
|
||||
assert "lastClickedRowId" in html
|
||||
assert "'ouvrir_ligne'" in html
|
||||
js = play_js_bundle()
|
||||
assert "applyOpenRowBindings" in js
|
||||
assert "lastClickedRowId" in js
|
||||
assert "'ouvrir_ligne'" in js
|
||||
|
||||
|
||||
def test_repeater_filter_literal_value(client, game, mail_object):
|
||||
@@ -224,7 +225,6 @@ def test_play_page_exposes_screen_show_trigger_helper(client, game):
|
||||
"""Le runtime jouable doit exécuter les nœuds "affichage" à chaque fois
|
||||
qu'un écran est montré (premier affichage, retour en arrière, données
|
||||
modifiées) — voir README, section correspondante."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "runScreenShowTriggers" in html
|
||||
assert "'affichage'" in html
|
||||
js = play_js_bundle()
|
||||
assert "runScreenShowTriggers" in js
|
||||
assert "'affichage'" in js
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Blocs de logique (screens/flow/blocks/) : regroupe les nœuds d'UN écran
|
||||
en sous-graphes nommés/décrits pour rester lisible même à l'échelle de
|
||||
milliers de nœuds (voir templates/screen_edit.html, onglet "🧩 Blocs de
|
||||
logique"). Un bloc est un automate FERMÉ : aucune arête possible entre deux
|
||||
nœuds de blocs différents (toute communication inter-blocs passe par les
|
||||
événements personnalisés, voir tests/test_custom_events.py)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
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 _add_flow_edge(client, slug, screen_id, from_node_id, to_node_id, from_port="out"):
|
||||
return client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/flow/edges/add",
|
||||
data=json.dumps({"from_node_id": from_node_id, "from_port": from_port, "to_node_id": to_node_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
def test_create_and_list_flow_blocks(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/new", data={
|
||||
"name": "Ouvrir la porte", "description": "Ouvre la porte au clic.",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
blocks = screens.list_flow_blocks(game, screen_id)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["name"] == "Ouvrir la porte"
|
||||
|
||||
|
||||
def test_orphan_nodes_are_migrated_into_a_default_block(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
# Nœud posé "avant" l'existence des blocs (aucun block_id transmis) —
|
||||
# doit être rattaché à un "Bloc principal" auto-créé au premier
|
||||
# appel de list_flow_blocks, sans rien perdre.
|
||||
node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage")
|
||||
|
||||
blocks = screens.list_flow_blocks(game, screen_id)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["name"] == "Bloc principal"
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["block_id"] == blocks[0]["id"]
|
||||
|
||||
# Idempotent : un second appel ne crée pas un second "Bloc principal".
|
||||
blocks_again = screens.list_flow_blocks(game, screen_id)
|
||||
assert len(blocks_again) == 1
|
||||
|
||||
|
||||
def test_node_created_with_a_block_id_is_not_touched_by_migration(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
block_id = screens.create_flow_block(game, screen_id, "Mon bloc")
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id,
|
||||
node_type="trigger", trigger_event="affichage", block_id=block_id,
|
||||
)
|
||||
blocks = screens.list_flow_blocks(game, screen_id)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["id"] == block_id
|
||||
assert screens.get_flow_node(game, node_id)["block_id"] == block_id
|
||||
|
||||
|
||||
def test_edge_between_two_different_blocks_is_rejected(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
block_a = screens.create_flow_block(game, screen_id, "Bloc A")
|
||||
block_b = screens.create_flow_block(game, screen_id, "Bloc B")
|
||||
node_a = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a)
|
||||
node_b = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_b)
|
||||
|
||||
resp = _add_flow_edge(client, game, screen_id, node_a, node_b)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# Une arête entre deux nœuds du MÊME bloc, elle, doit réussir.
|
||||
node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a)
|
||||
resp_ok = _add_flow_edge(client, game, screen_id, node_a, node_a2)
|
||||
assert resp_ok.status_code == 200
|
||||
|
||||
|
||||
def test_deleting_a_block_cascades_only_its_own_nodes_and_edges(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
block_a = screens.create_flow_block(game, screen_id, "Bloc A")
|
||||
block_b = screens.create_flow_block(game, screen_id, "Bloc B")
|
||||
|
||||
node_a1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a)
|
||||
node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a)
|
||||
edge_resp = _add_flow_edge(client, game, screen_id, node_a1, node_a2)
|
||||
assert edge_resp.status_code == 200
|
||||
edge_id = edge_resp.get_json()["id"]
|
||||
|
||||
node_b1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_b)
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/{block_a}/delete", data={}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
assert screens.get_flow_block(game, block_a) is None
|
||||
assert screens.get_flow_node(game, node_a1) is None
|
||||
assert screens.get_flow_node(game, node_a2) is None
|
||||
assert screens.list_flow_edges(game, screen_id) == [] or all(e["id"] != edge_id for e in screens.list_flow_edges(game, screen_id))
|
||||
|
||||
# Le bloc B et son nœud, eux, ne sont pas touchés.
|
||||
assert screens.get_flow_block(game, block_b) is not None
|
||||
assert screens.get_flow_node(game, node_b1) is not None
|
||||
|
||||
|
||||
def test_screen_edit_page_shows_correct_node_count_right_after_migration(client, game):
|
||||
"""routes/screens/screen_edit.py doit lancer list_flow_blocks() (qui
|
||||
migre les nœuds orphelins vers "Bloc principal") AVANT list_flow_nodes()
|
||||
— sinon la page affiche 0 nœud pour "Bloc principal" au tout premier
|
||||
chargement suivant la migration (block_id périmé en mémoire)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
_add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage")
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
assert b"Bloc principal" in resp.data
|
||||
# Le compte de nœuds (colonne "Nœuds") doit valoir 1, pas 0 — cherche
|
||||
# la cellule <td>1</td> qui suit le nom du bloc dans le tableau.
|
||||
html = resp.get_data(as_text=True)
|
||||
row = html[html.index("Bloc principal"):]
|
||||
assert re.search(r"<td>1</td>", row)
|
||||
|
||||
|
||||
def test_screen_edit_page_renders_blocks_tab(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
screens.create_flow_block(game, screen_id, "Ouvrir la porte", "Ouvre la porte. " * 5)
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
assert b"Blocs de logique" in resp.data
|
||||
assert b"Ouvrir la porte" in resp.data
|
||||
@@ -10,6 +10,8 @@ import re
|
||||
import db
|
||||
import screens
|
||||
|
||||
from conftest import screen_edit_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -105,6 +107,8 @@ def test_ordinary_fixed_row_target_is_unaffected(client, game):
|
||||
def test_clicked_row_option_appears_in_the_flow_node_editor(client, game):
|
||||
_create_email_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "FLOW_CLICKED_ROW_ID" in html
|
||||
assert "Ligne cliquée" in html
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
js = screen_edit_js_bundle()
|
||||
assert "FLOW_CLICKED_ROW_ID" in js
|
||||
assert "Ligne cliquée" in js
|
||||
|
||||
+5
-4
@@ -3,6 +3,8 @@ import re
|
||||
|
||||
import db
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -265,7 +267,6 @@ def test_play_page_click_binding_guards_against_duplicate_listeners(client, game
|
||||
ré-attachement — un vrai test de non-régression comportementale (clics
|
||||
répétés via un navigateur réel) a été exécuté manuellement pour valider
|
||||
le correctif : 5 clics sur +10 -> exactement +50, jamais plus."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "clickBound" in html
|
||||
assert "if (elDiv.dataset.clickBound) return;" in html
|
||||
js = play_js_bundle()
|
||||
assert "clickBound" in js
|
||||
assert "if (elDiv.dataset.clickBound) return;" in js
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -36,7 +38,6 @@ def test_play_page_exposes_activer_onglet_runtime(client, game):
|
||||
"""Le runtime jouable doit savoir exécuter "activer_onglet" (montrer un
|
||||
élément, masquer ses frères) — vérifie que le JS livré au joueur
|
||||
contient bien cette logique, sans dépendre d'un navigateur."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'activer_onglet'" in html
|
||||
assert "parentKey" in html
|
||||
js = play_js_bundle()
|
||||
assert "'activer_onglet'" in js
|
||||
assert "parentKey" in js
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
ne se déforment pas selon la fenêtre du joueur."""
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -137,4 +139,4 @@ def test_play_payload_includes_preview_aspect_per_screen(client, game):
|
||||
def test_play_page_has_letterboxing_frame(client, game):
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert 'id="playFrame"' in html
|
||||
assert "applyScreenAspect" in html
|
||||
assert "applyScreenAspect" in play_js_bundle()
|
||||
|
||||
Reference in New Issue
Block a user