Compare commits

...
6 Commits
Author SHA1 Message Date
w-vandal df0cfb944d Merge pull request 'Dev' (#6) from dev into main
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Reviewed-on: #6
2026-08-30 13:13:36 +00:00
williamandClaude Sonnet 5 bb84b7b377 Phase 0 : factorise compute_new_value + ajoute pytest/node test à la CI
Build and deploy / test (push) Failing after 13s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
1. Duplication éliminée avant que la Phase 2 (hasard/opérations
   mathématiques) n'en ajoute 6 de plus aux DEUX fichiers : la chaîne
   d'opérations quasi identique entre screens/data_actions/
   apply_data_action.py (champ d'objet) et apply_variable_action.py
   (variable globale) est factorisée dans un nouveau
   compute_operation.py::compute_new_value(operation, current, raw_value,
   is_decimal), réutilisé par les deux. Nouveau tests/test_compute_operation.py
   verrouille le comportement des 7 opérations existantes (dont les cas
   limites : valeur invalide, type décimal vs entier, opération inconnue)
   avant d'en ajouter d'autres.

2. .gitea/workflows/deploy.yml déployait en prod à chaque push sur main
   sans jamais exécuter la suite de tests — rien ne bloquait
   techniquement un commit cassé. Nouveau job "test" (pytest + node:test
   sur la logique pure de static/js/play/, via des conteneurs officiels
   plutôt que des actions du marketplace, cohérent avec le choix déjà
   fait dans ce fichier) tourne sur CHAQUE push (main ET dev, utile pour
   ce dépôt qui travaille sur dev) ; "build-and-push"/"deploy" gagnent un
   "needs: test" et restent réservés à main (filtre sur gitea.ref) — un
   push sur dev ne redéploie jamais la prod, seulement les tests.

Vérifié : 223 tests passent (8 nouveaux), YAML validé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:00:23 +02:00
williamandClaude Sonnet 5 fe807ba51e Phase -1 (suite) : découpe l'éditeur de screen_edit.html en modules JS
Même chantier que le commit précédent (moteur de jeu, play.html) —
templates/screen_edit.html était un unique fichier HTML+CSS+JS de 3312
lignes, tout l'éditeur (arborescence, panneaux flottants, canevas,
formulaire de propriétés, éditeur de flow à nœuds, blocs de logique,
timeline d'animation) vivant dans UN SEUL <script>.

Ce fichier est plus imbriqué que play.html : de nombreux appels
s'exécutent au niveau racine du script (pas seulement des déclarations
de fonctions), et JavaScript hoiste les déclarations `function` sur
TOUT le script — un appel au niveau racine peut donc référencer une
fonction déclarée PLUS LOIN dans le même fichier. Découper naïvement
casserait cet ordre implicite. Un audit dédié (analyse ligne par ligne
de chaque appel racine + son graphe d'appel transitif) a identifié 3
références "en avance" réelles, toutes regroupées dans la même zone
(initBuilderPanel()/toggleActionFields() → bindAspectButtons/
toggleElementPropertyValue/onDataDefinitionChange) — le découpage
respecte cette contrainte : chaque fichier est une TRANCHE SÉQUENTIELLE
de l'original (jamais une réorganisation), et cette zone spécifique
reste un seul fichier (panel-init.js) pour que le hoisting continue de
fonctionner exactement comme avant.

5 fichiers sous static/js/screen_edit/ :
- tree-panels.js — arborescence, menu contextuel, panneaux flottants
  gauche/droite, galerie d'icônes, modale de suppression/choix d'icône,
  glisser-déposer du canevas, panneau de propriétés (autosave).
- panel-init.js — (ré)initialisation du panneau central après chaque
  changement de sélection, filtres de répéteur/donnée liée, condition
  de visibilité, champs d'action du formulaire de nœud.
- flow-editor.js — éditeur de flow à nœuds (rendu du graphe, formulaire
  d'ajout de nœud, blocs de logique — currentBlockNodes/Edges).
- tabs-and-blocks.js — onglets du centre, panneaux flottants génériques
  (drag/resize/plein écran), modale d'un bloc de logique.
- animation-timeline.js — timeline d'animation (clips Animate.css/
  personnalisés).

Toutes les données injectées par Jinja (GAME_SLUG, SCREEN_ID,
DEFINITIONS_DATA, FLOW_NODES_INITIAL, ELEMENTS_LABELS, CUSTOM_EVENTS_MAP,
ANIM_CLIPS...) sont posées UNE FOIS par un petit <script> inline resté
dans le template, avant les <script src> — même patron que
static/js/play/. Le seul bout de logique resté inline est la toute
petite IIFE d'ouverture initiale (?tab=/?block=), qui dépend directement
de request.args et doit s'exécuter après que tous les fichiers soient
chargés.

tests/conftest.py : screen_edit_js_bundle() (même principe que
play_js_bundle(), Phase -1 précédente) — 4 tests qui vérifiaient la
présence de telle fonction/chaîne dans le HTML de l'éditeur (le JS y
était inline) sont mis à jour pour chercher dans ce bundle. Un des deux
échecs révélait un test déjà fragile (assert "Ligne cliquée" in html
vérifiait en réalité le TEXTE SOURCE d'un <script> inline, jamais du
HTML réellement rendu — ce texte ne peut plus s'y trouver une fois la
fonction qui le construit dynamiquement déplacée dans un fichier
externe) : corrigé pour vérifier le bundle JS + la disponibilité de la
route séparément.

Vérifié : 215 tests passent, syntaxe JS validée sur les 5 nouveaux
fichiers (node --check) et sur les <script> inline restants (rendus via
le client de test). Test manuel recommandé (édition complète d'une
scène : arborescence, propriétés, glisser-déposer, logique de flow,
blocs, timeline) avant de considérer ce découpage définitivement sans
risque — comme pour play.html, ce fichier n'a pas de harnais de test
DOM automatisé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 14:38:57 +02:00
williamandClaude Sonnet 5 dbada333d5 Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS
templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:59:11 +02:00
williamandClaude Sonnet 5 a445b72a6e Corrige "Ouvrir"/"Modifier" inertes dans l'onglet Blocs de logique
Deux bugs distincts, signalés par capture d'écran (bouton "Ouvrir" sans
effet, ligne d'édition affichée en permanence au lieu d'être cachée) :

1. onclick="openLogicBlockPanel({{ b.id }}, {{ b.name|tojson }})" cassait
   l'attribut HTML : tojson produit des guillemets DOUBLES (valides en
   JSON), qui terminaient prématurément l'attribut onclick="..." lui-même
   entre guillemets doubles — le gestionnaire de clic généré était donc
   tronqué et invalide, provoquant une erreur JS non interceptée qui
   arrêtait aussi tout le script restant dans la même balise <script>
   (dont l'IIFE qui devait poser window.openLogicBlockPanel). Corrigé en
   ne passant que l'id dans l'attribut et en retrouvant le nom du bloc
   côté client depuis FLOW_BLOCKS (déjà chargé) — plus aucune chaîne
   utilisateur à échapper dans un attribut HTML.

2. <tr class="hidden" id="blockEditRow..."> ne se cachait jamais : le
   CSS ne définissait .hidden que scopé (.floatPanel.hidden,
   .columnFilterMenu.hidden), jamais en règle générique — ajoutée dans
   styles/forge-custom.css.

Vérifié : 215 tests passent, syntaxe JS validée sur un scénario avec un
vrai bloc existant (reproduisant exactement la situation signalée),
onclick généré inspecté directement dans le HTML rendu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:48:25 +02:00
williamandClaude Sonnet 5 1b7706b357 Ajoute les blocs de logique : organise le graphe de flow en sous-graphes nommés
Le graphe de logique d'une scène s'affichait jusqu'ici sur un seul
canevas plat (toutes les scènes accumulant leurs nœuds sur la même
grille), ce qui ne tient pas à l'échelle dès qu'une scène évolue au fil
de l'avancée du joueur et accumule des centaines/milliers de nœuds.

Ajoute les "Blocs de logique" : un bloc regroupe un sous-ensemble de
nœuds/arêtes d'un écran sous un nom et une description (comme une
fonction). L'onglet "Logique de la scène" devient une liste de blocs
(nom, description tronquée à 3 phrases, éléments concernés, nombre de
nœuds, bouton "Ouvrir"). Ouvrir un bloc affiche SON graphe dans une
modale plein écran, redimensionnable et déplaçable (patron déjà mûr
dans game_dashboard.html, porté tel quel : makeFloatPanelDraggable/
Resizable/Fullscreenable).

Décision d'architecture : un bloc est un automate FERMÉ — impossible de
relier un nœud d'un bloc à un nœud d'un autre bloc (rejeté côté serveur
dans flow_edge_add.py). Toute communication entre deux blocs passe par
le système d'événements personnalisés déjà en place
(declencher_evenement / trigger_event="evenement").

Détails techniques :
- Nouvelle colonne _flow_nodes.block_id (nullable, sans FK — même
  rationale que trigger_element_id/target_element_id, voir
  screens/elements/delete_element.py) et nouvelle table _flow_blocks
  (screens/flow/ensure_flow_schema.py,
  screens/flow/blocks/ensure_flow_blocks_schema.py).
- Migration douce et automatique : les nœuds posés avant l'existence
  des blocs (block_id NULL) sont rattachés, à la première ouverture de
  l'onglet, à un "Bloc principal" auto-créé (screens/flow/blocks/
  list_flow_blocks.py) — aucun script de migration séparé, aucune
  donnée perdue.
- Suppression d'un bloc = cascade complète (bloc + tous ses nœuds/
  arêtes), patron identique à screens/custom_events/delete_custom_event.py
  mais scopé à un seul bloc plutôt que game-wide.
- Routes CRUD sous routes/flow_blocks/, montées comme routes/custom_events/.
- templates/screen_edit.html : FLOW (global unique) renommé en ALL_FLOW
  (toutes les données de l'écran) ; un seul bloc ouvert à la fois
  (modale unique, à la Unity) — currentBlockNodes()/currentBlockEdges()
  filtrent ALL_FLOW par CURRENT_BLOCK_ID à chaque rendu, sans tenir de
  seconde copie à synchroniser manuellement.

Vérifié : 215 tests passent (7 nouveaux dans tests/test_flow_blocks.py,
dont un qui verrouille l'ordre d'appel list_flow_blocks()/
list_flow_nodes() dans screen_edit.py — la migration douce doit tourner
AVANT le chargement des nœuds, sinon le compte de nœuds affiché juste
après une migration est périmé), syntaxe JS validée (script de
screen_edit.html rendu via le client de test puis node --check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:30:49 +02:00
53 changed files with 4158 additions and 3203 deletions
+34 -1
View File
@@ -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
+2
View File
@@ -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
+14
View File
@@ -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() + " …"
+1 -1
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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})
+1
View File
@@ -0,0 +1 @@
from . import flow_block_new, flow_block_edit, flow_block_delete
+14
View File
@@ -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"))
+13
View File
@@ -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"))
+14
View File
@@ -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"))
+9
View File
@@ -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),
)
+7
View File
@@ -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
+5 -23
View File
@@ -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:
try:
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)
+5 -23
View File
@@ -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:
try:
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)
+35
View File
@@ -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}")
View File
+20
View File
@@ -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
+19
View File
@@ -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
+26
View File
@@ -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()
+13
View File
@@ -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
+45
View File
@@ -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]
+17
View File
@@ -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()
+5
View File
@@ -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",
}
+13
View File
@@ -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()
+119
View File
@@ -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);
});
});
+193
View File
@@ -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();
}
+309
View File
@@ -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);
});
}
+133
View File
@@ -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 };
}
+65
View File
@@ -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); });
}
};
+178
View File
@@ -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);
});
}
+132
View File
@@ -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)));
});
}
+316
View File
@@ -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(); }
+616
View File
@@ -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, '&quot;') + '" 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, '&quot;') + '" 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();
});
}
+436
View File
@@ -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."; });
});
});
+137
View File
@@ -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.
+731
View File
@@ -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, '&quot;') + '">';
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();
});
});
}
+13
View File
@@ -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; }
+13
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+115 -2135
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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
+5 -2
View File
@@ -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):
+54
View File
@@ -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)
+3 -1
View File
@@ -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):
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+144
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -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
+3 -1
View File
@@ -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()