Compare commits

...
25 Commits
Author SHA1 Message Date
w-vandal ad04abc723 Merge pull request 'Dev' (#2) from dev into main
Build and deploy / deploy (push) Successful in 13s
Build and deploy / build-and-push (push) Successful in 1m9s
Reviewed-on: #2
2026-08-27 13:00:44 +00:00
williamandClaude Sonnet 5 82a14fcb2b Panneau valeur variable Objet/Tableau : bouton +Ajouter visible et sauvegarde auto
Deux retours utilisateur sur le panneau "Configurer la valeur" ajouté au
commit précédent :
- le bouton "+ Ajouter" (style is-outlined) se voyait mal dans le panneau
  sombre -> repassé en bouton "primary" pleine largeur, et déplacé sous la
  liste des lignes (là où on l'attend pour ajouter une ligne de plus,
  plutôt qu'au-dessus, hors du flux de lecture) ;
- le bouton "OK - utiliser cette valeur" était un aller-retour inutile :
  supprimé. Chaque changement (texte tapé, clé, type, ajout ou retrait
  d'une ligne) réécrit désormais tout de suite le JSON dans le champ
  cible via saveVarValuePanel(), écoutée sur les événements input/change
  du conteneur des lignes - exactement comme si on tapait directement
  dans le champ, sans étape de validation séparée.

Aucun changement Python (127 tests toujours au vert) ; vérifié la syntaxe
du <script> rendu de l'onglet Variables via node --check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:56:03 +02:00
williamandClaude Sonnet 5 1b24df9546 Éditeur à champs pour les variables Objet/Tableau (plus de JSON à taper)
L'édition en JSON brut (textarea) n'était pas adaptée à un public non
développeur. Remplacée par un panneau "Configurer la valeur" (déplaçable/
redimensionnable/plein écran, comme les autres) avec une ligne par clé
(Objet) ou par élément (Tableau) : un champ Clé (Objet seulement), un
type (Texte/Nombre/Oui-Non) et une valeur en texte libre — le JSON est
reconstruit automatiquement au clic sur "OK", jamais tapé à la main.

Un seul panneau partagé (#varValuePanel) pour toutes les variables : à
l'ouverture (openVarValuePanel(targetInputId, mode)), reconstruit une
ligne par entrée déjà présente dans le JSON actuel de l'<input> ciblé —
un objet/tableau imbriqué en valeur est préservé tel quel (JSON.stringify
en texte opaque) plutôt que perdu, même si cette valeur précise n'est pas
éditable via une ligne (portée volontairement limitée aux valeurs
simples : suffisant pour une configuration ou une liste, sans la
complexité d'un éditeur JSON récursif).

- Barre "+ Créer une variable" : le champ de valeur devient en lecture
  seule pour Objet/Tableau, avec un bouton "🔧 Configurer" à côté.
- Ligne d'une variable existante : la cellule Valeur montre un aperçu en
  lecture seule + le même bouton "🔧 Configurer" (au lieu du textarea
  JSON du commit précédent).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:41:37 +02:00
williamandClaude Sonnet 5 aaf9954446 Variables globales Objet/Tableau, lisibles via un chemin dans les conditions et filtres
Deux nouveaux types de variable globale (db/constants.py) : "objet" et
"tableau" — valeur stockée en JSON (colonne TEXT existante), avec
validation à la création/modification (db/global_vars/
coerce_structured_value.py) : un JSON invalide retombe sur un défaut sûr
("{}"/"[]") plutôt que de corrompre silencieusement la variable pour
toutes ses lectures suivantes. apply_variable_action.py n'a besoin
d'aucun changement — "definir_texte" écrit déjà n'importe quelle chaîne
telle quelle.

Nouvelle résolution de chemin, partagée (screens/rendering/
filter_repeater_rows.py::_resolve_variable_path) : navigue dans la
valeur JSON d'une variable selon un chemin ".champ"/"[index]" chaînable
(ex. ".arme.degats", "[0].valeur") — ne lève jamais, renvoie None si le
JSON est invalide ou qu'un segment du chemin ne correspond à rien.
Branchée à deux endroits, qui lisaient déjà une variable globale :

- La valeur de comparaison {{$nom_variable}} (filtres de Répéteur ET
  Condition de visibilité, qui partagent le même
  _resolve_filter_value()) accepte maintenant un chemin optionnel :
  {{$perso.nom}}, {{$scores[0]}}. Le sélecteur "Variable globale" du
  panneau de propriétés (screen_edit.html, .filterValueVariable) gagne un
  champ "Chemin optionnel" à côté du choix de variable — même regex
  étendue côté JS (_VAR_REF_RE) que côté Python (_VAR_REF_PATTERN), pour
  que la valeur round-trip correctement à la réouverture du panneau.

- La variable VÉRIFIÉE par une Condition de visibilité en mode "variable"
  (choisie via un <select>, pas la syntaxe {{$...}}) gagne son propre
  nouveau contrôle "Chemin dans la variable" (visibility_condition_
  controls.py) — nécessaire pour comparer un champ d'un Objet ou un
  élément d'un Tableau, pas seulement la variable entière.

game_dashboard.html (onglet Variables) : la valeur par défaut de la barre
de création devient un <textarea> (fonctionne aussi bien pour un JSON
multi-ligne qu'un scalaire court), et la cellule "Valeur" du tableau des
variables existantes devient un <textarea> quand le type est objet/
tableau.

4 nouveaux tests (tests/test_variable_object_array.py) : validation JSON
à la création, filtre de Répéteur avec chemin chaîné, condition de
visibilité avec accès par index de tableau, chemin invalide/absent sans
plantage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:28:23 +02:00
williamandClaude Sonnet 5 b21f56e364 Filtre de colonnes + panneau de détail d'une entrée (onglet Données)
Un objet avec beaucoup de champs rendait le tableau "Données
enregistrées" illisible (toutes les colonnes serrées sur une ligne,
valeurs tronquées) — deux ajouts pour compenser :

1) "🔧 Colonnes" : menu déroulant (une case à cocher par champ, toutes
cochées par défaut) au-dessus du tableau — décocher un champ masque sa
colonne (th + td, via [data-col]) sans reconstruire le tableau. Un seul
menu ouvert à la fois, fermé au clic ailleurs sur la page.

2) 👁️ par ligne : ouvre un panneau de détail (déplaçable/redimensionnable/
plein écran par défaut, comme les autres) listant TOUS les champs de
cette entrée, un par ligne, valeur complète non tronquée — reprend le
style .rowDetailField/.rowDetailLabel/.rowDetailValue de l'ancienne
modale Bulma de data_list.html (retirée, mais ce CSS ne dépendait pas du
template et a été gardé). Un seul panneau PAR OBJET (pas par ligne) :
son contenu est reconstruit à l'ouverture en lisant en direct la ligne
du tableau déjà affiché (via [data-row-id] sur chaque <tr>) — reflète
donc aussi une modification tout juste saisie mais pas encore
"Enregistrer"ée, sans aller-retour serveur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:17:10 +02:00
williamandClaude Sonnet 5 943bf265de Panneaux flottants en plein écran par défaut, avec bouton pour en sortir
Chaque panneau (Nouvel objet, Modifier un objet, Ajouter un champ,
Ajouter une entrée) s'ouvre désormais en plein écran par défaut (marge
de 12px) plutôt qu'en petite fenêtre centrée — plus confortable dès
qu'il y a plusieurs champs/entrées à voir en même temps. Un bouton ⛶
dans l'en-tête bascule vers/depuis la taille et la position précédentes
(mémorisées le temps de la session, pas persistées), pour qui préfère
un panneau plus petit à côté du reste du tableau de bord.

makeFloatPanelFullscreenable(panel, toggleBtn) — générique, posée sur
panel._fsToggle — appelée par les 4 familles de panneaux existantes.
makeFloatPanelDraggable()/makeFloatPanelResizable() sortent d'abord
proprement du plein écran si l'utilisateur interagit manuellement
(glisser l'en-tête ou tirer le coin) : sans ça, bottom/right encore
actifs en plein écran auraient repris la main sur la position/taille
qu'on vient de poser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:06:58 +02:00
williamandClaude Sonnet 5 03db681022 Panneaux dédiés pour "Ajouter un champ"/"Ajouter une entrée"
La barre en ligne (tous les champs alignés horizontalement) devenait
illisible au-delà d'une poignée de champs — un objet à 10 champs
donnait une ligne de saisie qui débordait largement de l'écran.

Remplacée par un simple bouton "+ Ajouter" qui ouvre un panneau dédié
(déplaçable et redimensionnable, comme les autres) avec un formulaire
VERTICAL — un champ par ligne, étiquette au-dessus, comme un formulaire
normal — plutôt qu'entassé sur une seule ligne. Un panneau "Ajouter un
champ" et un panneau "Ajouter une entrée" par objet, tous deux cachés
par défaut.

Réutilise makeFloatPanelDraggable()/makeFloatPanelResizable() (déjà
génériques, voir commit précédent) — aucun nouveau mécanisme de
glisser-déposer à écrire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:58:16 +02:00
williamandClaude Sonnet 5 0e390a679b Ajoute l'onglet "Données" au panneau objet, retire object_view.html
Suite de la demande : le panneau "Modifier un objet" (crayon ✏️, onglet
Objets du tableau de bord) a maintenant 2 sous-onglets — "Champs" (déjà
en place) et "Données", qui reprend data_list.html + data_form.html
(retirés) : ajouter une entrée (une ligne compacte avec le bon type de
champ par colonne — texte/nombre/case à cocher/date/relation, comme
l'ancien formulaire), modifier/supprimer une entrée existante (tableau
dense, cellules éditables en ligne, même principe que "Champs
existants").

Sous-onglets scopés au panneau de LEUR objet (switchObjectPanelTab(),
classes .objectPanelTabs/.objectPanelTabPanel distinctes de .builderTabs/
.builderTabPanel) — plusieurs objets ont chacun leurs propres
sous-onglets indépendants sur la même page, sans jamais interférer avec
les onglets du tableau de bord lui-même.

routes/games/game_dashboard.py fournit maintenant, par objet : ses lignes
(rows_by_definition), les libellés lisibles de ses champs relation
(relation_labels_by_definition, pour l'affichage) et leurs options
(relation_options_by_definition, pour les <select>), ainsi que
referenced_by_definition (avertissement permanent si un autre objet a
une relation vers celui-ci — repris de l'ancien object_view.py, affiché
maintenant en continu plutôt qu'après une tentative de suppression
échouée).

data_form.py (partagé par data_new/data_edit), data_delete.py et
object_delete.py redirigent maintenant vers le tableau de bord
(?edit=<id>&subtab=data, +?blocked_row=<id> si la suppression d'une
entrée est bloquée par une relation) au lieu de object_view/object_edit.

Piège évité : de nombreux tests déduisent l'id d'un objet fraîchement
créé du DERNIER SEGMENT du chemin dans le header Location d'une
redirection (.../objects/<id>) — rediriger object_new directement vers
le tableau de bord (chemin sans id) cassait donc 33 tests d'un coup.
Fix : object_view.py reste en place, mais seulement comme redirecteur
(plus de page rendue) — object_new redirige toujours vers lui (chemin
qui se termine par l'id, donc les tests continuent de fonctionner), qui
redirige à son tour vers le tableau de bord.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:13:05 +02:00
williamandClaude Sonnet 5 b8dfdcb205 Corrige la nav pjax, panneau déplaçable/redimensionnable pour éditer un objet
1) Bug pjax trouvé : swapDocument() cherchait "header.topbar", qui n'a
jamais existé (c'est un <nav>, pas un <header>) — la barre de navigation
du jeu (ajoutée après pjax.js) n'était donc jamais mise à jour pendant
une navigation pjax : absente en arrivant sur un jeu sans Ctrl+F5, et
inversement laissée en place en revenant sur l'accueil où elle n'a rien
à faire. Fix : tout ce qui est hors <main> mais doit changer d'une page
à l'autre (topbar + barre du jeu) est regroupé dans un nouveau conteneur
stable #pageChrome, que pjax.js remplace en bloc — plus fiable qu'un
sélecteur qui ne correspondait à rien. CSS (flex:0 0 auto des layouts
plein-écran) mis à jour en conséquence.

2) Renommages demandés : onglet/panneau "Écrans du jeu" -> "Écrants",
"Éléments de jeu" -> "Templates" (tab, titre de panneau, bouton
"+ Créer un template", état vide, infobulle, confirmation de
suppression).

3) Le crayon ✏️ sur une ligne d'objet ouvre désormais un panneau
déplaçable ET redimensionnable (nouveau coin de redimensionnement
générique, .floatPanelResizeHandle) au lieu de naviguer vers
object_edit.html (retirée) — un panneau par objet, pré-rendu et
caché par défaut. Reprend telles quelles les fonctionnalités de
l'ancienne page : renommer l'objet, ajouter un champ (ligne compacte),
modifier/supprimer un champ existant (tableau dense déjà repris pour
"Nouvel objet"), supprimer l'objet. Les routes de champs (object_field_
add/edit/delete) et object_edit lui-même redirigent maintenant vers le
tableau de bord avec ?edit=<id>, pour rouvrir automatiquement le bon
panneau après l'action plutôt que de le fermer silencieusement.

makeFloatPanelDraggable()/makeFloatPanelResizable() généralisées pour
être partagées entre "Nouvel objet" et les panneaux d'édition, plutôt
que du code dupliqué par panneau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:56:49 +02:00
williamandClaude Sonnet 5 e1216e99d5 Rend le panneau "Nouvel objet" compact — lignes de tableau, pas des cartes
Chaque champ prenait une grosse carte (~250px de haut : libellés Bulma
pleine taille, "Retirer ce champ" en texte) — inutilisable pour un objet
à 10+ champs, ce qui est pourtant l'usage visé par ce panneau.

Remplacé par une vraie ligne de tableau dense, sur le même principe que
"Champs existants" dans object_edit.html (déjà compact et sobre dans le
reste de l'outil) : une ligne = un champ, colonnes Nom/Type/Objet
lié/Mini/Maxi/Obligatoire, action "Retirer" réduite à une icône. Les
colonnes conditionnelles (Objet lié pour une relation, Mini/Maxi pour un
nombre) restent TOUJOURS présentes — sans quoi les colonnes de lignes
différentes ne s'aligneraient plus — seul leur contenu bascule entre le
vrai champ de saisie et un espace réservé "—", au lieu de masquer toute
la cellule comme avant.

object_form.js adapté en conséquence : bounds désormais 2 cibles
séparées (mini/maxi, chacune dans sa propre cellule) au lieu d'une seule
enveloppe commune, et chaque bascule s'accompagne de celle de son
espace réservé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:04:13 +02:00
williamandClaude Sonnet 5 d875557254 Fusionne les 4 pages de création dans le dashboard, retire le titre/BDD
Suite de la demande précédente : les 4 pages autrefois listées dans la
barre de navigation n'existent plus en tant que pages séparées — tout
vit désormais dans les onglets du tableau de bord (commit précédent) ou,
pour la création d'un objet, dans un panneau déplaçable.

- screens_list.html + sa route (screens_list) : supprimés (l'onglet
  "Écrans du jeu" du dashboard couvre déjà tout : créer, réordonner,
  éditer, supprimer). screen_new/screen_move/screen_delete redirigent
  maintenant vers le dashboard (tab=screens) au lieu de cette page.
- element_types.html : supprimé, mais la route element_types est
  conservée (GET redirige vers le dashboard, POST — utilisé par la barre
  de création repliable de l'onglet "Éléments de jeu" — continue de
  fonctionner). element_type_edit/element_type_delete redirigent aussi
  vers le dashboard.
- game_variables.html + sa route (game_variables) : supprimés (l'onglet
  "Variables" du dashboard couvre déjà tout). create_global_var/
  global_var_edit/global_var_delete redirigent vers le dashboard
  (tab=variables) au lieu de cette page.
- object_form.html : supprimé. La route object_new (POST) est conservée
  pour traiter la soumission du panneau — voir plus bas — mais ne rend
  plus de page pour un GET (redirige vers le dashboard).

Nouveau panneau déplaçable "Nouvel objet" sur le dashboard (bouton
"+ Nouvel objet" de l'onglet Objets) : réutilise .floatPanel/
.floatPanelHeader/.floatPanelBody (déjà utilisées dans l'éditeur d'écran)
avec une nouvelle variante centrée (.floatPanel--center) et son propre
glisser-déposer minimal (pas de position persistée, contrairement aux
panneaux de l'éditeur d'écran — inutile pour un panneau ouvert
ponctuellement). Contenu et script (object_form.js) repris tels quels de
l'ancienne page.

base.html : la barre de navigation du jeu n'a donc plus que 2 liens —
"📊 Tableau de bord" (nouveau) et "▶️ Jouer" (toujours en dernier).

game_dashboard.html : titre du jeu et chemin de la base de données
retirés (redondants avec le nom déjà visible dans l'onglet du
navigateur/la barre de nav).

Les liens "crée-en un"/"gérer les variables" dans l'éditeur d'écran
(screen_edit.html) pointent maintenant vers le dashboard avec le bon
onglet (?tab=...), lu et appliqué au chargement de la page
(switchDashTab() côté JS).

2 tests (test_screens_and_elements.py) mis à jour : ils vérifiaient le
contenu des pages supprimées (element-types, screens) — adaptés pour
vérifier la même chose sur le dashboard, qui porte maintenant cette
information.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:54:19 +02:00
williamandClaude Sonnet 5 65450a5719 Refait le dashboard en fenêtre à onglets avec de vrais tableaux
Le précédent dashboard (grille de cartes compactes) ne correspondait pas
à ce que l'utilisateur voulait : une seule fenêtre avec de vrais tableaux
de données (denses, colonnes nettes), un bouton "Créer" par catégorie
dans l'en-tête, et une navigation horizontale pour passer d'une catégorie
à l'autre.

Réutilise telles quelles .builderTabs/.builderTabBtn/.builderTabPanel
(déjà utilisées pour "Écran / Logique / Timeline" dans l'éditeur d'écran)
plutôt que d'inventer un 2e système d'onglets — même sensation partout
dans l'outil. Un onglet par catégorie (Écrans/Objets/Éléments de
jeu/Variables), chacun avec :
  - un bouton "+ Créer" dans l'en-tête qui révèle une barre de création
    compacte (repliée par défaut) — sauf pour les Objets, dont la
    création (plusieurs champs typés) reste sur sa propre page dédiée,
    trop complexe pour tenir dans une barre ;
  - le VRAI tableau de gestion de cette catégorie (colonnes, actions),
    repris tel quel de screens_list.html/element_types.html/
    game_variables.html plutôt que réinventé en version appauvrie.

La page défile désormais normalement (retrait de body.objectEditBody/
content-objectEdit, pensés pour une hauteur figée avec défilement
interne) — une liste peut être longue, pas besoin d'un défilement séparé
par panneau ici.

routes/games/game_dashboard.py fournit en plus variable_types
(db.GLOBAL_VARIABLE_TYPES) pour la barre de création de variable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:15:37 +02:00
williamandClaude Sonnet 5 8f45169209 Retire le fil d'Ariane, centre/réordonne la barre de nav, refait le dashboard
Trois demandes distinctes de l'utilisateur, regroupées car elles touchent
toutes à la navigation d'un jeu :

1. Fil d'Ariane retiré (devenu redondant avec la barre de navigation du
   jeu ajoutée au commit précédent) : bloc breadcrumb_wrap retiré de
   base.html, et son override ({% block breadcrumb %}) retiré des 9
   templates qui le définissaient encore. .breadcrumbBar (CSS) retiré,
   y compris des règles flex:0 0 auto de body.objectEditBody/builderBody.

2. Liens de .gameNavBar centrés (justify-content:center).

3. "Jouer" déplacé en dernier lien (c'est une action à part — ouvre
   l'aperçu jouable dans un nouvel onglet — pas un éditeur de plus comme
   les 4 autres).

4. game_dashboard.html devient un vrai tableau de bord : une grille de
   cartes (Écrans/Objets/Éléments de jeu/Variables), chacune listant les
   entrées existantes avec un accès direct (clic = éditeur concerné) et
   un lien "Gérer" vers la page dédiée pour créer/réorganiser. Remplace
   l'ancien panneau "Créer" (redondant avec la barre de navigation
   persistante) et la simple table "Objets définis". routes/games/
   game_dashboard.py alimente maintenant aussi screen_list, element_types
   (+ usage) et variables, en réutilisant list_screens/
   list_element_types/element_type_usage_count/list_global_variables déjà
   utilisés ailleurs.

Vérifié en rendant toutes les pages concernées via le client de test
Flask : barre de nav présente partout où un `game` est dans le contexte
(absente sur l'accueil), fil d'Ariane absent partout, ordre des liens
avec "Jouer" en dernier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:04:09 +02:00
williamandClaude Sonnet 5 de89115534 Ajoute une barre de navigation persistante entre les éditeurs d'un jeu
Jusqu'ici, les liens vers les différents éditeurs d'un jeu (Écrans,
Éléments de jeu, Variables, Jouer, Objet) n'existaient que dans le
panneau "Créer" du tableau de bord (game_dashboard.html) — changer
d'éditeur obligeait à revenir sur cette page à chaque fois.

base.html expose désormais une 2e barre (.gameNavBar), sous la barre
"Forge Engine" et au-dessus du fil d'Ariane, reprenant ces mêmes 5
liens — visible sur TOUTE page qui met un `game` dans le contexte du
template (déjà fait par chaque route pour le fil d'Ariane, donc aucun
changement de route nécessaire), absente sur l'accueil (liste des jeux,
pas de jeu courant). L'onglet correspondant à la section actuelle est
mis en évidence via un simple préfixe sur request.path.

static/style.css : nouvelle barre en ligne (contrairement à .navList/
.navRow, empilés verticalement dans le panneau "Créer" du tableau de
bord, réutilisés tels quels ailleurs). Ajoutée aux règles flex:0 0 auto
de body.objectEditBody/body.builderBody (mise en page plein-écran des
éditeurs) aux côtés de .topbar/.breadcrumbBar, sans quoi elle aurait
cassé la répartition de hauteur figée de ces pages.

Vérifié en rendant plusieurs routes via le client de test Flask : barre
absente sur l'accueil, présente partout ailleurs (tableau de bord, liste
des écrans, éditeur d'écran normal ET d'écran-modèle, éléments de jeu,
variables, nouvel objet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 09:20:05 +02:00
williamandClaude Sonnet 5 ffedb16d8a Inclut les écrans-modèles dans gameData.flows/animations
Cause du "la logique posée sur le modèle ne s'applique pas dans la
scène" (persistant malgré les 2 commits précédents) : full_game_payload()
appelait list_screens(slug) SANS include_templates=True — un
écran-modèle (ex. "Modèle : mail content") n'apparaissait donc jamais
dans gameData.flows ni gameData.animations côté client. findTriggerNode()
cherche pourtant bien un déclencheur dans TOUTES les clés de
gameData.flows — mais si l'écran-modèle n'y a même pas d'entrée, il n'y
a rien à trouver, quelle que soit la justesse de cette recherche.

Fix : les nœuds/fils de logique et les clips d'animation sont désormais
lus pour TOUS les écrans (list_screens(slug, include_templates=True)),
dans une boucle séparée de celle qui construit payload_screens — celle-
ci continue de ne lister que les vrais écrans, pour ne jamais rendre un
écran-modèle comme un <div class="playScreen"> à part entière (il n'est
jamais affiché tel quel, seulement rechargé en direct à l'intérieur d'un
élément qui l'utilise).

Vérifié sur les vraies données du jeu de test : gameData.flows contient
désormais bien l'écran 3 (le modèle "mail content"), avec son
déclencheur "Au survol" et son action "Rendre visible" ; payload_screens
ne contient toujours que l'écran réel (1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:42:20 +02:00
williamandClaude Sonnet 5 e653f95d37 Ouvre les onglets Logique/Animation dans l'éditeur d'un modèle réutilisable
Ces onglets ("Logique de la scène", "Timeline d'animation") étaient
masqués sur l'écran-modèle d'un élément de jeu réutilisable, avec un
commentaire expliquant pourquoi : à l'époque, ses enfants étaient COPIÉS
en base avec un NOUVEL id à chaque exemplaire posé
(instantiate_template_tree) — une logique/animation posée dans le
modèle référencerait donc des ids qui n'existent plus une fois
l'élément utilisé ailleurs.

Ce mécanisme a depuis été retiré (voir le commentaire dans
list_elements.py) : le contenu d'un élément de jeu réutilisable est
désormais TOUJOURS rechargé EN DIRECT depuis son écran-modèle à chaque
affichage, avec les MÊMES ids à chaque exemplaire. Combiné au commit
précédent (findTriggerNode/runFlowFrom/collectAnimationClips côté
play.html, qui exécutent maintenant correctement un déclencheur/une
animation posé dans un modèle, où qu'il soit utilisé), la restriction
de cette page n'avait donc plus lieu d'être — elle bloquait justement la
fonctionnalité que le commit précédent venait de rendre possible.

Les données nécessaires (flow_nodes/flow_edges/elements du modèle,
etc.) étaient déjà calculées sans condition par la route
(routes/screens/screen_edit.py) ; seul le template masquait les deux
onglets et leur contenu derrière {% if not screen.is_template %}.
switchBuilderTab() détecte déjà la présence des panneaux via
HAS_FLOW_PANEL/HAS_ANIM_PANEL (document.getElementById), donc aucun
changement JS n'était nécessaire.

Vérifié en rendant réellement /game/test/screens/3/edit (l'écran-modèle
"mail content") via le client de test Flask : les deux onglets sont
maintenant bien présents, et l'écran normal (id=1) n'est pas affecté.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 07:41:48 +02:00
williamandClaude Sonnet 5 17c5e93d8d Fait fonctionner logique et animations d'un modèle réutilisable partout où il est posé
Jusqu'ici, la logique (déclencheurs Au clic/Au survol/Fin du survol) et
les animations posées dans l'éditeur de l'écran-MODÈLE d'un élément de
jeu réutilisable (ex. "mail content") sur SES PROPRES enfants ne
s'exécutaient jamais quand cet élément était simplement posé sur une
autre scène : findTriggerNode() cherchait bien le déclencheur dans tous
les écrans (modèles compris) mais runFlowFrom() n'exécutait ensuite le
graphe que dans l'écran RÉELLEMENT affiché — le nœud trouvé n'existait
pas dans ce graphe-là, donc rien ne se déclenchait, silencieusement.
Même limitation pour les animations, dont la timeline ne lisait que les
clips propres à l'écran affiché.

Logique (templates/play.html) :
- findTriggerNode() renvoie désormais { node, screenId } plutôt que
  juste le nœud, pour transmettre l'écran D'ORIGINE du déclencheur (qui
  peut être un écran-modèle).
- runFlowFrom(nodeId, flowScreenId) accepte un 2e paramètre optionnel
  (par défaut l'écran affiché, comportement inchangé pour tout le
  reste) pour exécuter le graphe dans le BON écran.
- bindClicks()/bindHoverTriggers() passent maintenant cet écran
  d'origine à runFlowFrom(). runScreenShowTriggers() (déclencheur "À
  l'affichage de l'écran") reste volontairement inchangé — hors scope,
  ambiguïté sur plusieurs exemplaires d'un même modèle sur un écran.

Animations (screens/payload/full_game_payload.py, templates/play.html) :
- Le payload expose désormais element_types (element_type_id -> id de
  son écran-modèle), via screens.list_element_types() déjà existant.
- collectAnimationClips(screenId) rassemble récursivement les clips de
  l'écran affiché ET de tout écran-modèle utilisé par un de ses
  éléments (garde anti-boucle, dédoublonnage par écran).
- applyAnimationClip() cible désormais TOUS les exemplaires d'un id
  d'élément (querySelectorAll, plus querySelector) : un enfant de
  modèle garde le même id à chaque exemplaire, y compris pour chaque
  ligne d'un Répéteur utilisant ce modèle comme gabarit de ligne.

Limite connue, non corrigée ici (pas la demande) : une action "Modifier
un élément" ciblant un enfant de modèle reste, elle, scopée au premier
exemplaire trouvé dans le DOM (document.querySelector singulier dans
runActionNode/applyElementProperty) — sans impact pour un modèle posé
une seule fois par écran, comme dans le cas d'usage actuel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 07:33:25 +02:00
williamandClaude Sonnet 5 19d3810164 Calcule rendered_html pour CHAQUE élément, pas seulement le premier niveau
Cause racine réelle des 2 précédents correctifs (commits 20adfa4,
f4a7a73, 5e4e226, f016a81) qui n'avaient AUCUN effet visible malgré des
redémarrages en règle : list_elements() ne calculait "rendered_html" que
pour les éléments de PREMIER NIVEAU (parent_id NULL). Le nouveau ciblage
côté JS (play.html) cherche pourtant à repérer, pour un élément imbriqué
comme le Répéteur ou "mail content", s'il porte lui-même un marqueur ou
un {{champ}} non résolu — mais côté serveur, ces éléments n'avaient tout
simplement PAS de rendered_html du tout : `e.rendered_html` valait
`undefined`, donc `hasMarker`/`hasUnresolvedPlaceholder` retombaient
toujours à `false` pour eux, laissant SEUL le conteneur racine de
premier niveau comme candidat — reproduisant exactement le bug d'origine
(tout l'écran régénéré à chaque clic) qu'aucun des correctifs côté JS ne
pouvait donc jamais résoudre, quelle que soit la justesse de leur
logique de filtrage.

Fix : chaque élément (imbriqué ou non) reçoit désormais son propre
rendered_html — un élément imbriqué s'y retrouve deux fois (une fois
dans le rendered_html de son ancêtre de premier niveau, utilisé pour le
rendu HTML initial de la page ; une fois dans le sien propre, utilisé
par le ciblage précis côté JS). Vérifié par simulation directe sur le
payload réel du jeu de test : les cibles calculées sont maintenant
exactement les 3 jauges, le Répéteur et "mail content" — plus jamais le
conteneur racine de l'écran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 06:49:41 +02:00
williamandClaude Sonnet 5 f016a81dc6 Résout {{champ}} en place au lieu de recréer le nœud (zéro flash, façon React)
L'utilisateur a raison de pointer que le vrai souci n'était pas un bug
isolé mais l'APPROCHE elle-même : detruire puis reconstruire un nœud du
DOM à chaque clic (même bien ciblé, comme depuis les 2 derniers commits)
cause toujours un flash visuel, puisque tout état transitoire du
sous-arbre (visibilité posée par "Modifier un élément", focus...) est
perdu et reconstruit à neuf. C'est ce qui donnait l'impression trompeuse
d'un "rechargement" — un comportement JS parfaitement normal quand on
manipule le DOM ainsi, mais évitable : c'est exactement le problème que
la réconciliation ciblée de React (ne patcher que ce qui a changé,
jamais recréer un nœud pour rien) résout côté framework.

applyOpenRowBindings() ne remplace donc plus JAMAIS le nœud de l'élément
ciblé (ex. "mail content") — il patche directement, en place :
  - un nœud TEXTE contenant {{champ}} est coupé en 3 (texte avant, un
    <span data-bind-field="champ">, texte après) LA PREMIÈRE FOIS
    SEULEMENT ; toute ouverture suivante se contente de changer le
    textContent de ce span — plus aucune reconstruction ensuite.
  - un ATTRIBUT contenant {{champ}} (ex. href="{{link_real_url}}") voit
    son gabarit d'origine mémorisé sur data-bind-attr-<nom> au premier
    passage, pour être recalculé et réécrit directement à chaque fois
    sans jamais reconstruire le nœud.

Plus aucun nœud n'étant détruit, la sauvegarde/restauration de l'état
visuel transitoire (ajoutée dans un commit précédent pour compenser
cette destruction) devient inutile et est retirée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 06:24:42 +02:00
williamandClaude Sonnet 5 5e4e226794 Corrige le filtre "élément le plus spécifique" (logique inversée)
Les deux commits précédents (ciblage des éléments imbriqués dans
applyOpenRowBindings() et refreshRuntimeData()) n'avaient AUCUN effet
visible, confirmé par l'utilisateur après redémarrage du serveur — cause
trouvée : leur filtre "ne garder que les éléments les plus spécifiques"
vérifiait l'inverse de ce qu'il fallait.

Un CONTENEUR contient toujours le HTML de ses descendants dans son
propre rendered_html — donc un ancêtre "a le marqueur/placeholder" quasi
systématiquement dès qu'un descendant l'a. Le filtre précédent excluait
un élément candidat si un de ses ANCÊTRES était candidat — ce qui, vu ce
qui précède, ne gardait quasiment jamais que l'ancêtre RACINE de
l'écran, reproduisant exactement le bug d'origine (tout l'écran
régénéré) que ces commits visaient à corriger.

Fix : inversion du sens du filtre — un candidat est désormais exclu si
l'un de ses PROPRES DESCENDANTS est aussi candidat (le descendant sera
déjà régénéré individuellement, inutile de régénérer aussi son
ancêtre). Vérifié par une simulation Node.js reproduisant la structure
réelle de l'écran de test (Répéteur niché sous 2 conteneurs, "mail
content" sous 2 autres) : la nouvelle logique cible bien uniquement le
Répéteur et "mail content", plus jamais le conteneur racine de l'écran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:04:01 +02:00
williamandClaude Sonnet 5 f4a7a7340e Cible aussi les éléments imbriqués dans refreshRuntimeData()
Même défaut que celui corrigé dans applyOpenRowBindings() (commit
précédent), mais dans le second endroit qui régénère l'écran après un
changement de donnée : refreshRuntimeData() ne pouvait régénérer que les
éléments de PREMIER NIVEAU (seuls eux ont un data-el-id sur leur wrapper
.playElement). Un Répéteur niché dans un conteneur — comme celui de cet
écran — n'est jamais du premier niveau : c'est donc son ANCÊTRE de
premier niveau qui portait le marqueur "repeaterItem" à l'intérieur et
se faisait régénérer en entier à sa place, potentiellement l'écran
complet (jauges, onglets compris) si l'écran n'a qu'un seul gros
conteneur racine. C'était la cause réelle du "rechargement" toujours
visible après le précédent correctif : celui-ci ne portait que sur
applyOpenRowBindings(), pas sur cette 2e régénération déclenchée par
"Modifier une donnée"/"Modifier une variable".

Fix : même principe que le commit précédent — cible chaque élément
marqué (repeaterItem/jaugeBar/visibilityGated) directement via son
data-element-id, à n'importe quel niveau d'imbrication, en ne gardant
que les plus "hauts" parmi les éléments marqués pour ne jamais régénérer
un même nœud deux fois.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:51:57 +02:00
williamandClaude Sonnet 5 20adfa4169 Ne régénère plus que l'élément concerné par "Ouvrir la ligne cliquée"
Cause du "rechargement" perçu par l'utilisateur (et du flash à vide sur
la ligne de Répéteur juste cliquée, visible sur une vidéo de repro) :
applyOpenRowBindings() ne pouvait cibler que les éléments de PREMIER
NIVEAU de l'écran (seuls eux ont un wrapper .playElement dans le DOM).
Sur cet écran, "mail content" est niché à 2 conteneurs de profondeur, et
le SEUL élément de premier niveau est le conteneur racine de tout
l'écran — donc chaque clic sur une ligne de Répéteur régénérait
littéralement tout l'écran (jauges, onglets, Répéteur compris) pour ne
mettre à jour qu'un seul panneau de détail, avec un flash à vide pendant
la reconstruction.

Fix : applyOpenRowBindings() cible maintenant directement, à n'importe
quel niveau d'imbrication, le(s) élément(s) qui portent réellement un
{{champ}} non résolu (repéré via document.querySelector
('[data-element-id=...]'), disponible sur CHAQUE élément rendu, pas
seulement les élément de premier niveau) — et seulement les plus "hauts"
parmi eux, pour ne jamais régénérer un même nœud deux fois. Seul "mail
content" est donc désormais remplacé (via replaceWith), sans toucher au
Répéteur ni au reste de l'écran. La sauvegarde/restauration de l'état
visuel transitoire (style, classes, dataset hors clickBound/hoverBound/
hoverTriggerBound) suit le même principe, appliquée au nœud remplacé et
à ses descendants.

Aucun aller-retour réseau n'a jamais eu lieu ici (refreshRuntimeData()
utilise déjà fetch/JSON, pas de navigation de page) — la sensation de
rechargement venait uniquement de la granularité du remplacement DOM,
pas d'un manque d'AJAX.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:02:49 +02:00
williamandClaude Sonnet 5 5114372cc4 Réattache les gestionnaires de clic après "Ouvrir la ligne cliquée"
Root cause enfin identifiée grâce à un log console instrumenté par
l'utilisateur (indispensable — sans lui les précédents correctifs
visaient le mauvais chemin de code, refreshRuntimeData(), qui ne se
déclenchait même pas dans ce scénario) : l'action "Ouvrir la ligne
cliquée" (ouvrir_ligne) appelle applyOpenRowBindings() DIRECTEMENT,
sans jamais rappeler bindClicks() ensuite — contrairement à
refreshRuntimeData(), qui elle le fait déjà correctement.

Si l'écran a un Répéteur ET un panneau de détail (avec des {{champ}})
posés dans un même conteneur parent, applyOpenRowBindings() régénère
tout ce sous-arbre — Répéteur compris — pour résoudre les {{champ}} du
panneau. Les lignes du Répéteur héritent alors de nœuds DOM tout neufs,
sans le moindre écouteur de clic (le garde-fou anti-doublon de
bindClicks() repose sur dataset.clickBound, absent sur un nœud neuf,
mais bindClicks() lui-même n'était jamais rappelé pour les attacher).

Symptôme exact reproduit : le tout premier clic sur une ligne fonctionne
(gestionnaires posés au chargement de la page), plus AUCUN clic ne
répond ensuite sur AUCUNE ligne, sans erreur console — confirmé par un
log montrant runFlowFrom() jamais réinvoqué au clic suivant, et
manuellement réparé en rappelant bindClicks() à la main dans la
console.

Fix : bindClicks()/bindHoverTexts()/bindHoverTriggers() sont maintenant
rappelés juste après applyOpenRowBindings() dans le gestionnaire de
"Ouvrir la ligne cliquée", comme ils le sont déjà dans
refreshRuntimeData().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 08:18:43 +02:00
williamandClaude Sonnet 5 ca7daf9a31 Réattache toujours les gestionnaires de clic après un rafraîchissement de données
Test de diagnostic déterminant : après le blocage rapporté (plus aucune
ligne de Répéteur ne répond après le tout premier clic), appeler
manuellement bindClicks() dans la console suffisait à tout réparer —
donc ni le DOM ni le graphe de logique n'étaient en cause, seule
l'INVOCATION de bindClicks() manquait à un moment donné.

Cause : refreshRuntimeData() faisait un retour anticipé silencieux
(`if (!screenData || !screenDiv) return;`) qui sautait, avec lui,
TOUT le reste de la fonction — y compris bindClicks(), bindHoverTexts()
et bindHoverTriggers() — sans le moindre message d'erreur, laissant les
éléments régénérés (Répéteur compris) sans aucun écouteur pour le reste
de la partie.

Fix : ce garde-fou ne protège plus désormais que le bloc de régénération
du contenu de l'écran (qui a effectivement besoin de screenData/
screenDiv) ; les réattachements, eux, s'exécutent toujours ensuite, quoi
qu'il arrive. Un try/catch autour de la régénération ajoute en prime un
filet de sécurité : toute erreur inattendue s'y loggera clairement au
lieu de bloquer silencieusement le reste, si jamais ce n'était pas
l'unique cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 07:47:19 +02:00
williamandClaude Sonnet 5 024da11934 Corrige le blocage des clics après le premier rafraîchissement de données
Régression introduite par le commit précédent (préservation de l'état
visuel transitoire dans applyOpenRowBindings) : la restauration du
dataset complet d'un nœud copiait aussi clickBound/hoverBound/
hoverTriggerBound — des indicateurs INTERNES au moteur (voir bindClicks/
bindHoverTexts/bindHoverTriggers), jamais un état posé par une action
"Modifier un élément". Un nœud tout juste régénéré se retrouvait donc
marqué "déjà lié" à tort, alors qu'aucun écouteur de clic n'y était
réellement rattaché : bindClicks() le voyait déjà "bound" et sautait
son rattachement, rendant l'élément silencieusement inerte pour le
reste de la partie.

Symptôme rapporté : dans un écran avec un Répéteur ET un panneau de
détail utilisant des {{champ}}, le premier clic sur une ligne fonctionne
(exécuté par les gestionnaires posés au chargement de la page), mais
plus aucun clic ne répond ensuite sur AUCUNE ligne — le Répéteur étant
regénéré dans le même sous-arbre que le panneau de détail (ancêtre
commun avec des {{champ}} non résolus), donc concerné par la même
restauration de dataset.

Fix : exclure ces trois clés internes de la sauvegarde/restauration —
seul l'état réellement transitoire (style inline, classes, data-toggle-*
posés par "Modifier un élément") doit survivre à la regénération.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 07:31:40 +02:00
49 changed files with 1939 additions and 1054 deletions
+7 -1
View File
@@ -18,10 +18,16 @@ FIELD_TYPES = {
# Types disponibles pour une variable globale (voir db/global_vars/) — un
# sous-ensemble de FIELD_TYPES : ni "relation" (une variable globale n'a pas
# d'objet à pointer) ni "texte_long"/"date" (pas gérés par
# apply_variable_action.py, qui ne coerce que ces 4 types).
# apply_variable_action.py, qui ne coerce que ces 4 types). "objet"/
# "tableau" sont, eux, propres aux variables (pas de colonne SQL réelle,
# contrairement à un champ d'objet) : la valeur est stockée telle quelle
# (colonne TEXT) sous forme de JSON, lue via un chemin ".champ"/"[index]"
# (voir _resolve_variable_path dans screens/rendering/filter_repeater_rows.py).
GLOBAL_VARIABLE_TYPES = {
"texte": FIELD_TYPES["texte"],
"nombre_entier": FIELD_TYPES["nombre_entier"],
"nombre_decimal": FIELD_TYPES["nombre_decimal"],
"booleen": FIELD_TYPES["booleen"],
"objet": {"label": "Objet (JSON)"},
"tableau": {"label": "Tableau (JSON)"},
}
+21
View File
@@ -0,0 +1,21 @@
import json
def coerce_structured_value(var_type, value):
"""Pour une variable "objet"/"tableau" (voir db/constants.py), la
valeur stockée doit rester du JSON analysable — sinon la prochaine
lecture (_resolve_variable_path, screens/rendering/
filter_repeater_rows.py) échouerait silencieusement à chaque fois.
Un JSON invalide retombe donc sur un défaut sûr ("{}"/"[]") plutôt que
d'être stocké tel quel. Les autres types ne sont jamais touchés ici."""
if var_type not in ("objet", "tableau"):
return value
text = (value or "").strip()
fallback = "{}" if var_type == "objet" else "[]"
if not text:
return fallback
try:
json.loads(text)
except (TypeError, ValueError):
return fallback
return text
+2
View File
@@ -1,5 +1,6 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .coerce_structured_value import coerce_structured_value
def create_global_variable(slug, name, var_type="texte", default_value=""):
@@ -11,6 +12,7 @@ def create_global_variable(slug, name, var_type="texte", default_value=""):
name = (name or "").strip()
if not name:
return None
default_value = coerce_structured_value(var_type, default_value)
ensure_global_vars_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone()
+9 -7
View File
@@ -1,16 +1,18 @@
from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema
from .coerce_structured_value import coerce_structured_value
def update_global_variable(slug, var_id, var_type, value):
"""Modifie le type et la valeur d'une variable existante depuis la page
de gestion (voir game_variables.html) — le NOM reste volontairement
immuable après création : c'est par ce nom qu'une condition de
visibilité ou une action "Modifier une variable" la référence (voir
get_global_variable.py), la renommer casserait ces réglages en
silence."""
"""Modifie le type et la valeur d'une variable existante depuis le
tableau de bord (onglet Variables, game_dashboard.html) — le NOM reste
volontairement immuable après création : c'est par ce nom qu'une
condition de visibilité ou une action "Modifier une variable" la
référence (voir get_global_variable.py), la renommer casserait ces
réglages en silence."""
value = coerce_structured_value(var_type, str(value))
ensure_global_vars_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, str(value), var_id))
conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, value, var_id))
conn.commit()
conn.close()
+1 -1
View File
@@ -9,4 +9,4 @@ from core.flask_app import app
def element_type_delete(slug, element_type_id):
if screens.element_type_usage_count(slug, element_type_id) == 0:
screens.delete_element_type(slug, element_type_id)
return redirect(url_for("element_types", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
+1 -1
View File
@@ -13,4 +13,4 @@ def element_type_edit(slug, element_type_id):
name = request.form.get("name", "").strip() or None
bound_definition_id = request.form.get("bound_definition_id") or None
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
return redirect(url_for("element_types", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
+8 -11
View File
@@ -1,6 +1,5 @@
from flask import render_template, request, redirect, url_for
from flask import request, redirect, url_for
import db
import screens
from core.flask_app import app
@@ -8,18 +7,16 @@ from core.flask_app import app
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
def element_types(slug):
game = db.game_meta(slug)
"""La création/gestion d'un élément de jeu se fait désormais depuis
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
création (barre de création repliable de cet onglet). Un GET direct
redirige vers le tableau de bord."""
if request.method == "POST":
name = request.form.get("name", "").strip()
icon = request.form.get("icon", "").strip() or "🧩"
bound_definition_id = request.form.get("bound_definition_id") or None
if name:
screens.create_element_type(slug, name, icon, bound_definition_id)
return redirect(url_for("element_types", slug=slug))
types = screens.list_element_types(slug)
usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in types}
definitions = db.list_definitions(slug)
return render_template(
"element_types.html", game=game, types=types, usage=usage, widgets=screens.WIDGETS,
definitions=definitions,
)
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
+52 -1
View File
@@ -1,12 +1,63 @@
from flask import render_template
import db
import screens
from core.flask_app import app
@app.route("/game/<slug>")
def game_dashboard(slug):
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
accès direct à chaque élément — la barre de navigation du jeu (voir
base.html) permet déjà de sauter directement vers chaque éditeur, cette
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
liens."""
game = db.game_meta(slug)
definitions = db.list_definitions(slug)
return render_template("game_dashboard.html", game=game, definitions=definitions)
# Détail complet (avec ses champs) de chaque objet, pour le panneau
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
# l'ouverture (voir openObjectEditPanel()).
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
# objet, les libellés lisibles de ses champs relation (pour l'affichage
# du tableau) et les options possibles de ces mêmes champs relation
# (pour le formulaire "Ajouter une entrée").
rows_by_definition = {}
relation_labels_by_definition = {}
relation_options_by_definition = {}
referenced_by_definition = {}
for d in definitions:
full = definitions_full[d["id"]]
rows_by_definition[d["id"]] = db.list_rows(slug, full)
relation_labels = {}
relation_options = {}
for f in full["fields"]:
if f["type"] == "relation":
options = db.relation_options(slug, f["relation_definition_id"])
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
relation_options[f["id"]] = options
relation_labels_by_definition[d["id"]] = relation_labels
relation_options_by_definition[d["id"]] = relation_options
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
screen_list = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
variables = db.list_global_variables(slug)
return render_template(
"game_dashboard.html", game=game, definitions=definitions,
definitions_full=definitions_full,
rows_by_definition=rows_by_definition,
relation_labels_by_definition=relation_labels_by_definition,
relation_options_by_definition=relation_options_by_definition,
referenced_by_definition=referenced_by_definition,
screen_list=screen_list, element_types=element_types,
element_type_usage=element_type_usage, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
)
-1
View File
@@ -1,4 +1,3 @@
from .create_global_var import create_global_var
from .game_variables import game_variables
from .global_var_edit import global_var_edit
from .global_var_delete import global_var_delete
+1 -1
View File
@@ -15,4 +15,4 @@ def create_global_var(slug):
default_value = request.form.get("default_value") or ""
if name:
db.create_global_variable(slug, name, var_type, default_value)
return redirect(url_for("game_variables", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
-19
View File
@@ -1,19 +0,0 @@
from flask import render_template
import db
from core.flask_app import app
@app.route("/game/<slug>/variables")
def game_variables(slug):
"""Page de gestion des variables globales (voir db/global_vars/) : un
objet "jeu" implicite dont les propriétés/valeurs sont visibles partout
(condition de visibilité, action "Modifier une variable"), plutôt
qu'attachées à un objet de données précis."""
game = db.game_meta(slug)
variables = db.list_global_variables(slug)
return render_template(
"game_variables.html", game=game, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES,
)
+1 -1
View File
@@ -8,4 +8,4 @@ from core.flask_app import app
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
def global_var_delete(slug, var_id):
db.delete_global_variable_by_id(slug, var_id)
return redirect(url_for("game_variables", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
+1 -1
View File
@@ -10,4 +10,4 @@ def global_var_edit(slug, var_id):
var_type = request.form.get("type") or "texte"
value = request.form.get("value") or ""
db.update_global_variable(slug, var_id, var_type, value)
return redirect(url_for("game_variables", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="variables"))
+5 -4
View File
@@ -13,8 +13,9 @@ def data_delete(slug, definition_id, row_id):
referencing = db.rows_referencing(slug, definition_id, row_id)
if referencing:
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
# une relation. On revient à la liste, qui explique pourquoi via un
# paramètre de requête simple plutôt qu'une session/flash message.
return redirect(url_for("object_view", slug=slug, definition_id=definition_id, blocked_row=row_id))
# une relation. On revient au panneau de cet objet (onglet
# Données), qui explique pourquoi via un paramètre de requête
# simple plutôt qu'une session/flash message.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
db.delete_row(slug, definition, row_id)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
+9 -17
View File
@@ -1,32 +1,24 @@
from flask import render_template, request, redirect, url_for, abort
from flask import request, redirect, url_for, abort
import db
def _data_form(slug, definition_id, row):
game = db.game_meta(slug)
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
(data_form.html, retirée) — cette fonction ne fait donc plus que
traiter la soumission de ce formulaire. Un GET direct redirige vers le
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
Données."""
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
# Pour chaque champ de type relation, on prépare les options du <select>
# à partir des lignes déjà enregistrées de l'objet lié.
relation_options = {}
for f in definition["fields"]:
if f["type"] == "relation":
relation_options[f["id"]] = db.relation_options(slug, f["relation_definition_id"])
if request.method == "POST":
if row is None:
db.insert_row(slug, definition, request.form)
else:
db.update_row(slug, definition, row["id"], request.form)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return render_template(
"data_form.html",
game=game,
definition=definition,
relation_options=relation_options,
row=row,
)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
+6 -5
View File
@@ -10,9 +10,10 @@ def object_delete(slug, definition_id):
referenced_by = db.definitions_referencing(slug, definition_id)
if referenced_by:
# Suppression bloquée : d'autres objets ont une relation vers
# celui-ci. On revient à l'écran de l'objet, qui affiche déjà cet
# avertissement (referenced_by), plutôt que de casser ces relations
# silencieusement.
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
# celui-ci. On revient à son panneau, qui affiche déjà cet
# avertissement (referenced_by_definition, voir game_dashboard.py)
# en permanence, pas seulement après une tentative — plutôt que de
# casser ces relations silencieusement.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
db.delete_definition(slug, definition_id)
return redirect(url_for("game_dashboard", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
+11 -15
View File
@@ -1,4 +1,4 @@
from flask import render_template, request, redirect, url_for, abort
from flask import request, redirect, url_for, abort
import db
@@ -7,22 +7,18 @@ from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
def object_edit(slug, definition_id):
game = db.game_meta(slug)
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
existing_definitions = [d for d in db.list_definitions(slug) if d["id"] != definition_id]
"""Renommer un objet + gérer ses champs se fait désormais depuis un
panneau déplaçable/redimensionnable sur le tableau de bord (voir
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
qu'une page dédiée — cette route ne fait donc plus que traiter le
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
le panneau de cet objet (?edit=<id>)."""
if request.method == "POST":
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
new_name = request.form.get("object_name", "").strip()
if new_name and new_name != definition["name"]:
db.rename_definition(slug, definition_id, new_name)
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
return render_template(
"object_edit.html",
game=game,
definition=definition,
existing_definitions=existing_definitions,
field_types=db.FIELD_TYPES,
)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
+1 -1
View File
@@ -17,4 +17,4 @@ def object_field_add(slug, definition_id):
"min_value": request.form.get("field_min") or None,
"max_value": request.form.get("field_max") or None,
})
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
+1 -1
View File
@@ -8,4 +8,4 @@ from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
def object_field_delete(slug, definition_id, field_id):
db.delete_field(slug, definition_id, field_id)
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
+1 -1
View File
@@ -17,4 +17,4 @@ def object_field_edit(slug, definition_id, field_id):
min_value=request.form.get("field_min") or None,
max_value=request.form.get("field_max") or None,
)
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
+7 -11
View File
@@ -1,4 +1,4 @@
from flask import render_template, request, redirect, url_for
from flask import request, redirect, url_for
import db
@@ -8,20 +8,16 @@ from .parse_field_rows import _parse_field_rows
@app.route("/game/<slug>/objects/new", methods=["GET", "POST"])
def object_new(slug):
game = db.game_meta(slug)
existing_definitions = db.list_definitions(slug)
"""La création d'un objet se fait désormais depuis un panneau déplaçable
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
externe obsolète) redirige simplement vers le tableau de bord."""
if request.method == "POST":
name = request.form.get("object_name", "").strip()
fields = _parse_field_rows(request.form)
if name and fields:
definition_id = db.create_definition(slug, name, fields)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return render_template(
"object_form.html",
game=game,
existing_definitions=existing_definitions,
field_types=db.FIELD_TYPES,
)
return redirect(url_for("game_dashboard", slug=slug))
+12 -27
View File
@@ -1,33 +1,18 @@
from flask import render_template, request, abort
import db
from flask import redirect, url_for
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>")
def object_view(slug, definition_id):
game = db.game_meta(slug)
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
rows = db.list_rows(slug, definition)
# Pour l'affichage : traduit chaque id de relation en un libellé lisible
# (ex. "Salle serveur (#1)") au lieu d'un identifiant numérique brut.
relation_labels = {}
for f in definition["fields"]:
if f["type"] == "relation":
options = db.relation_options(slug, f["relation_definition_id"])
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
referenced_by = db.definitions_referencing(slug, definition_id)
blocked_row_id = request.args.get("blocked_row", type=int)
blocked_row_refs = db.rows_referencing(slug, definition_id, blocked_row_id) if blocked_row_id else None
return render_template(
"data_list.html", game=game, definition=definition, rows=rows,
relation_labels=relation_labels, referenced_by=referenced_by,
blocked_row_id=blocked_row_id, blocked_row_refs=blocked_row_refs,
)
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
retirée : les données vivent désormais dans l'onglet "Données" du
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
route reste néanmoins en place, comme pur redirecteur, pour deux
raisons : un lien externe/ancien vers cette URL continue de mener
quelque part de sensé, et surtout beaucoup de tests (et certains
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
segment du chemin renvoyé par le header Location d'une redirection
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
termine par l'id), qui redirige ensuite vers le tableau de bord."""
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
+1 -1
View File
@@ -1 +1 @@
from . import screens_list, screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect
+2 -2
View File
@@ -9,6 +9,6 @@ from core.flask_app import app
def screen_delete(slug, screen_id):
referencing = screens.screens_referencing(slug, screen_id)
if referencing:
return redirect(url_for("screens_list", slug=slug, blocked_screen=screen_id))
return redirect(url_for("game_dashboard", slug=slug, tab="screens", blocked_screen=screen_id))
screens.delete_screen(slug, screen_id)
return redirect(url_for("screens_list", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
+1 -1
View File
@@ -9,4 +9,4 @@ from core.flask_app import app
def screen_move(slug, screen_id):
direction = -1 if request.form.get("direction") == "up" else 1
screens.move_screen(slug, screen_id, direction)
return redirect(url_for("screens_list", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
-12
View File
@@ -1,12 +0,0 @@
from flask import render_template
import db
import screens
from core.flask_app import app
@app.route("/game/<slug>/screens")
def screens_list(slug):
game = db.game_meta(slug)
return render_template("screens_list.html", game=game, screen_list=screens.list_screens(slug))
+1 -1
View File
@@ -11,4 +11,4 @@ def screens_new(slug):
if name:
screen_id = screens.create_screen(slug, name)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
return redirect(url_for("screens_list", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
+16 -8
View File
@@ -7,12 +7,21 @@ from ..rendering.render_element_html import render_element_html
def list_elements(slug, screen_id, enforce_visibility=False):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls
les éléments de premier niveau (parent_id NULL) reçoivent un
"rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut
déjà, en le générant récursivement, le HTML de tout ce qui est posé à
l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une
deuxième fois séparément.
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
celui d'un conteneur/répéteur/groupe de champs inclut, en le générant
récursivement, le HTML de tout ce qui est posé à l'intérieur — un
élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le
rendered_html de son ancêtre de premier niveau, utilisé pour le rendu
HTML initial de la page ; une fois dans le sien propre) : c'est ce
dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/
refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué
(ex. un panneau de détail posé dans un conteneur) sans reconstruire tout
son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce
changement, seuls les éléments de premier niveau recevaient un
rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui
retombait donc toujours sur la régénération de son ancêtre de premier
niveau, quelle que soit la finesse du ciblage côté JS.
enforce_visibility : True uniquement pour le mode jouable (voir
full_game_payload.py) — applique la "condition de visibilité" de chaque
@@ -66,6 +75,5 @@ def list_elements(slug, screen_id, enforce_visibility=False):
children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
for d in result:
if not d.get("parent_id"):
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
return result
+29 -1
View File
@@ -5,6 +5,7 @@ from ..elements.list_elements import list_elements
from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
def full_game_payload(slug):
@@ -23,6 +24,21 @@ def full_game_payload(slug):
for s in screens_:
elements = list_elements(slug, s["id"], enforce_visibility=True)
payload_screens.append({**s, "elements": elements})
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
# écrans, écrans-modèles compris (include_templates=True) — pas
# seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable
# (ex. "mail content") peut avoir son propre déclencheur/sa propre
# animation posée dans l'éditeur de SON écran-modèle (voir
# screen_edit.html) : sans ses entrées ici, gameData.flows/
# gameData.animations n'auraient jamais contenu cet écran-modèle côté
# client, et findTriggerNode()/collectAnimationClips() (templates/
# play.html) — pourtant déjà écrits pour les chercher — n'auraient
# jamais rien trouvé. On ne les ajoute PAS à payload_screens : un
# écran-modèle ne doit jamais être rendu comme un vrai <div
# class="playScreen"> (il n'est jamais affiché tel quel, seulement
# rechargé en direct à l'intérieur d'un élément qui l'utilise).
for s in list_screens(slug, include_templates=True):
flows[str(s["id"])] = {
"nodes": list_flow_nodes(slug, s["id"]),
"edges": list_flow_edges(slug, s["id"]),
@@ -42,4 +58,16 @@ def full_game_payload(slug):
]
fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]]
return {"screens": payload_screens, "flows": flows, "animations": animations, "data": data, "fields_meta": fields_meta}
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
# aussi être joués à l'affichage de cet écran — sans quoi une animation
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
}
+39 -3
View File
@@ -1,3 +1,4 @@
import json
import re
import db
@@ -12,8 +13,41 @@ _REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/)
# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans
# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend
# toujours un point ("Objet.champ").
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}]+)\s*\}\}$")
# toujours un point ("Objet.champ"). Le 2e groupe capture un CHEMIN
# optionnel à l'intérieur de la variable ({{$perso.nom}}, {{$scores[0]}},
# chaînable : {{$perso.arme.degats}}) — utile seulement pour une variable
# de type "objet"/"tableau" (voir _resolve_variable_path ci-dessous), sans
# effet sur une variable scalaire (chemin vide dans ce cas).
_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}\[\]]+)((?:\.[^.{}\[\]]+|\[\d+\])*)\s*\}\}$")
# Un segment de chemin est soit ".champ" (clé d'objet) soit "[index]"
# (élément de tableau, 0 = premier) — voir _VAR_REF_PATTERN ci-dessus et le
# contrôle "Chemin dans la variable" de la Condition de visibilité
# (visibility_condition_controls.py).
_PATH_SEGMENT_RE = re.compile(r"\.([^.\[\]]+)|\[(\d+)\]")
def _resolve_variable_path(raw_value, path):
"""Navigue dans une valeur JSON (chaîne stockée en base, colonne TEXT)
selon un chemin ".champ"/"[index]" chaînable. Ne lève jamais : chemin
vide -> valeur brute inchangée (utile pour une variable scalaire, où le
chemin est toujours vide) ; JSON invalide, clé/index absent ou valeur
intermédiaire pas navigable -> None — même convention que le reste de
ce fichier (objet/variable introuvable -> ne matche rien plutôt que
planter)."""
if not path:
return raw_value
try:
current = json.loads(raw_value) if raw_value else None
except (TypeError, ValueError):
return None
for m in _PATH_SEGMENT_RE.finditer(path):
key, index = m.group(1), m.group(2)
try:
current = current[key] if key is not None else current[int(index)]
except (KeyError, IndexError, TypeError):
return None
return current
def _field_column(definition, field_name):
@@ -45,7 +79,9 @@ def _resolve_filter_value(slug, raw_value):
var_match = _VAR_REF_PATTERN.match(stripped)
if var_match:
variable = db.get_global_variable(slug, var_match.group(1).strip())
return variable.get("value") if variable else None # variable supprimée depuis : ne matche rien plutôt que planter
if not variable:
return None # variable supprimée depuis : ne matche rien plutôt que planter
return _resolve_variable_path(variable.get("value"), var_match.group(2))
m = _REF_PATTERN.match(stripped)
if not m:
+10 -2
View File
@@ -1,6 +1,6 @@
import db
from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column
from .filter_repeater_rows import _resolve_filter_value, _resolve_variable_path, _compare, _field_column
def is_element_visible(slug, attrs, ctx=None):
@@ -39,7 +39,15 @@ def is_element_visible(slug, attrs, ctx=None):
variable = db.get_global_variable(slug, var_name)
if not variable:
return True # variable supprimée depuis : ne bloque jamais l'affichage
return _compare(variable.get("value"), operator, expected, variable.get("type") or "texte")
value = variable.get("value")
# "Chemin dans la variable" (voir visibility_condition_controls.py)
# : nécessaire pour comparer un CHAMP d'une variable "objet" ou un
# ÉLÉMENT d'une variable "tableau", pas seulement la variable
# entière — même syntaxe ".champ"/"[index]" que {{$var.chemin}}.
path = attrs.get("_visibilite_cond_variable_chemin")
if path:
value = _resolve_variable_path(value, path)
return _compare(value, operator, expected, variable.get("type") or "texte")
definition_id = attrs.get("_visibilite_cond_definition_id")
field_name = attrs.get("_visibilite_cond_champ")
@@ -17,6 +17,12 @@ VISIBILITY_CONDITION_CONTROLS = [
default="", group=_GROUP),
c_select("visibilite_cond_variable", "Variable", "attr:_visibilite_cond_variable",
[("", "(sélectionner une variable)")], default="", group=_GROUP),
# Utile seulement si la variable choisie ci-dessus est de type
# "objet"/"tableau" (db/constants.py) : sans chemin, c'est la variable
# ENTIÈRE (son JSON tel quel) qui est comparée — voir
# _resolve_variable_path dans filter_repeater_rows.py pour la syntaxe.
c_text("visibilite_cond_variable_chemin", "Chemin dans la variable (optionnel)",
"attr:_visibilite_cond_variable_chemin", placeholder="ex. .nom ou [0].valeur", default="", group=_GROUP),
c_select("visibilite_cond_definition_id", "Objet", "attr:_visibilite_cond_definition_id",
[("", "(sélectionner un objet)")], default="", group=_GROUP),
c_select("visibilite_cond_champ", "Champ", "attr:_visibilite_cond_champ",
+20 -4
View File
@@ -17,12 +17,28 @@
var NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal'];
function wireRow(row) {
// Ligne de TABLEAU dense (une ligne = un champ, comme "Champs
// existants" dans object_edit.html) : "Objet lié" et "Mini"/"Maxi"
// gardent toujours leur colonne (jamais masquée, sinon les colonnes
// des différentes lignes ne s'alignent plus) — seul leur CONTENU
// bascule entre le vrai champ de saisie et un espace réservé "—"
// quand il ne s'applique pas au type choisi.
var typeSelect = row.querySelector('.fieldTypeSelect');
var relationWrap = row.querySelector('.relationTarget');
var boundsWrap = row.querySelector('.boundsTarget');
var relationTarget = row.querySelector('.relationTarget');
var relationPlaceholder = row.querySelector('.relationPlaceholder');
var boundsTargets = row.querySelectorAll('.boundsTarget');
var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder');
function toggle(target, placeholder, visible) {
target.style.display = visible ? '' : 'none';
placeholder.style.display = visible ? 'none' : '';
}
function syncFieldTypeVisibility() {
relationWrap.style.display = typeSelect.value === 'relation' ? 'block' : 'none';
if (boundsWrap) boundsWrap.style.display = NUMERIC_FIELD_TYPES.includes(typeSelect.value) ? 'flex' : 'none';
var isRelation = typeSelect.value === 'relation';
var isNumeric = NUMERIC_FIELD_TYPES.includes(typeSelect.value);
toggle(relationTarget, relationPlaceholder, isRelation);
boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); });
}
typeSelect.addEventListener('change', syncFieldTypeVisibility);
syncFieldTypeVisibility();
+7 -6
View File
@@ -5,8 +5,9 @@
* générées en Jinja) — on intercepte simplement chaque clic sur un lien
* interne et chaque soumission de formulaire interne, on va chercher la
* page suivante nous-mêmes en arrière-plan (fetch), et on ne remplace que
* <title>, la classe du <body>, l'en-tête (fil d'Ariane) et le contenu
* principal (<main>) — au lieu de laisser le navigateur tout recharger.
* <title>, la classe du <body>, #pageChrome (topbar + barre de navigation
* du jeu, voir base.html) et le contenu principal (<main>) — au lieu de
* laisser le navigateur tout recharger.
* L'URL affichée et l'historique (bouton "précédent") restent corrects
* grâce à history.pushState/popstate.
*
@@ -77,10 +78,10 @@
currentMain.className = newMain.className;
currentMain.innerHTML = newMain.innerHTML;
var newHeader = doc.querySelector("header.topbar");
var currentHeader = document.querySelector("header.topbar");
if (newHeader && currentHeader) {
currentHeader.innerHTML = newHeader.innerHTML;
var newChrome = doc.querySelector("#pageChrome");
var currentChrome = document.querySelector("#pageChrome");
if (newChrome && currentChrome) {
currentChrome.innerHTML = newChrome.innerHTML;
}
mergeHeadAssets(doc.head);
+83 -9
View File
@@ -14,12 +14,20 @@ input[type=text], input[type=number], input[type=date], textarea, select{
}
input[type=checkbox]{ width:auto; margin-top:6px; }
/* .topbar/.breadcrumbBar : Bulma fournit déjà la mise en page (navbar,
breadcrumb) — ces classes ne servent plus qu'à les repérer dans les
layouts plein-écran (body.builderBody/objectEditBody, voir plus bas) et à
les rattacher au thème sombre. */
/* .topbar : Bulma fournit déjà la mise en page (navbar) — cette classe ne
sert plus qu'à la repérer dans les layouts plein-écran
(body.builderBody/objectEditBody, voir plus bas) et à la rattacher au
thème sombre. */
.topbar{ border-bottom:1px solid var(--border); }
.breadcrumbBar{ padding:10px 20px; background:transparent; border-bottom:1px solid var(--border); margin-bottom:0; }
/* Barre de navigation d'UN JEU (voir base.html) — toujours visible entre
les différents éditeurs d'un même jeu, en ligne (contrairement à
.navList/.navRow ci-dessous, empilés verticalement dans le panneau
"Créer" du tableau de bord). */
.gameNavBar{ display:flex; flex-wrap:wrap; justify-content:center; gap:6px; padding:8px 20px; background:transparent; border-bottom:1px solid var(--border); }
.gameNavLink{ display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
.gameNavLink:hover{ background:var(--panel2); border-color:var(--accent); }
.gameNavLink.active{ background:var(--panel2); border-color:var(--accent); color:var(--accent); }
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
@@ -30,6 +38,11 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
.formField{ margin-bottom:6px; }
button{ background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:9px 14px; cursor:pointer; font-size:13px; }
/* Un <button> qui doit se comporter comme un <a> visuellement (ex. ouvrir
un panneau plutôt que suivre un href, mais garder l'apparence d'un lien
dans un tableau) — annule le style bouton par défaut ci-dessus. */
.linkLikeButton{ background:none; border:none; padding:0; color:var(--accent); text-decoration:none; font-size:inherit; }
.linkLikeButton:hover{ text-decoration:underline; }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }
button.smallBtn{ padding:5px 10px; font-size:11.5px; }
button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
@@ -65,8 +78,9 @@ button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
.dataTable td{ max-width:220px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ---------- Détail d'une entrée (bouton 👁️) ---------- */
/* Contenu de la modale Bulma (.modal/.modal-card, voir data_list.html) —
affiche chaque champ en entier, sans troncature. */
/* Contenu du panneau de détail (voir openRowDetailPanel() dans
game_dashboard.html) — affiche chaque champ en entier, sans troncature,
contrairement au tableau dense qui les tronque. */
.rowDetailField{ padding:10px 0; border-bottom:1px solid var(--border); }
.rowDetailField:last-child{ border-bottom:none; }
.rowDetailLabel{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; margin-bottom:4px; }
@@ -96,7 +110,7 @@ button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
a beaucoup de champs, pour garder une vue d'ensemble sans perdre le
formulaire "Ajouter un champ" hors de l'écran. */
body.objectEditBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
body.objectEditBody > .topbar, body.objectEditBody > .breadcrumbBar{ flex:0 0 auto; }
body.objectEditBody > #pageChrome{ flex:0 0 auto; }
body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.content-objectEdit{ width:100%; display:flex; flex-direction:column; min-height:0; }
@@ -131,7 +145,7 @@ body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden
ce sont les zones internes (panneaux, écran, panneau de logique) qui
défilent chacune de leur côté si besoin. */
body.builderBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
body.builderBody > .topbar, body.builderBody > .breadcrumbBar{ flex:0 0 auto; }
body.builderBody > #pageChrome{ flex:0 0 auto; }
body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.content-builder{
@@ -182,8 +196,22 @@ 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; }
/* 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. */
.floatPanelResizeHandle{
position:absolute; right:0; bottom:0; width:16px; height:16px; cursor:nwse-resize;
background:linear-gradient(135deg, transparent 50%, var(--border) 50%, var(--border) 60%, transparent 60%, transparent 70%, var(--border) 70%, var(--border) 80%, transparent 80%);
border-radius:0 0 10px 0;
}
.floatPanel--left{ left:24px; width:280px; }
.floatPanel--right{ right:24px; width:340px; }
.floatPanel--center{ left:50%; width:560px; max-width:calc(100vw - 48px); transform:translateX(-50%); }
/* Plein écran (voir makeFloatPanelFullscreenable() dans game_dashboard.html)
— juste un repère visuel (bordure accent) sur le panneau agrandi, tout le
positionnement réel est posé directement en style inline par le JS (pour
pouvoir revenir exactement à la position/taille d'avant en sortant). */
.floatPanel--fullscreen{ border-color:var(--accent); }
.floatPanelHeader{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:8px 10px; border-bottom:1px solid var(--border); background:var(--panel2);
@@ -421,6 +449,15 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
.builderTabPanel.active{ display:flex; }
/* Sous-onglets Champs/Données À L'INTÉRIEUR d'un panneau "Modifier un
objet" (voir game_dashboard.html) — classes distinctes de .builderTabs/
.builderTabBtn/.builderTabPanel (utilisées par le style, réutilisé tel
quel) pour que switchDashTab() (onglets du tableau de bord lui-même,
ciblage global) n'aille jamais les toucher par erreur. */
.objectPanelTabs{ margin:14px 0 10px; }
.objectPanelTabPanel{ display:none; flex-direction:column; }
.objectPanelTabPanel.active{ display:flex; }
.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; }
@@ -484,3 +521,40 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.animClipResizeHandle{ position:absolute; right:0; top:0; width:8px; height:100%; cursor:ew-resize; }
.animKeyframeRow{ align-items:flex-start; }
.animKeyframeRow textarea{ font-family:monospace; font-size:11px; }
/* ---------- Tableau de bord d'un jeu (game_dashboard.html) ---------- */
/* Une seule fenêtre, un onglet par catégorie (écrans/objets/éléments de
jeu/variables) — réutilise .builderTabs/.builderTabBtn/.builderTabPanel
(voir plus bas, éditeur d'écran) pour la même sensation de "fenêtre à
onglets" qu'ailleurs dans l'outil, plutôt que d'inventer un 2e système.
Contrairement à leur usage dans l'éditeur d'écran, ici la page défile
normalement (pas de hauteur figée) : une liste peut être longue, pas
besoin d'un défilement interne séparé par panneau. */
.dashTabCount{ font-size:10px; color:var(--text-dim); background:var(--panel2); border-radius:999px; padding:1px 6px; margin-left:4px; }
.dashPanelHeader{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.dashPanelHeader h2{ margin:0; }
.dashCreateBar{ display:flex; gap:8px; align-items:center; margin-bottom:12px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--panel); }
.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; }
.dashDangerZone{ margin-top:28px; }
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
beaucoup de champs rend le tableau illisible, ce menu permet d'en
masquer certains (voir toggleDataColumn() : agit directement sur
[data-col] plutôt que de reconstruire le tableau). */
.columnFilterWrap{ position:relative; display:inline-block; }
.columnFilterMenu{
position:absolute; top:100%; left:0; margin-top:4px; z-index:70;
background:var(--panel); border:1px solid var(--border); border-radius:8px;
padding:8px; box-shadow:0 12px 40px rgba(0,0,0,.55);
display:flex; flex-direction:column; gap:2px; min-width:180px; max-height:280px; overflow-y:auto;
}
.columnFilterMenu.hidden{ display:none; }
.columnFilterOption{ display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:4px; font-size:12.5px; white-space:nowrap; }
.columnFilterOption:hover{ background:var(--panel2); }
/* ---------- Panneau "Configurer la valeur" (variable Objet/Tableau) ---------- */
/* La colonne "Clé" n'a de sens que pour un Objet (paires clé/valeur) — un
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
game_dashboard.html. */
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
+27 -7
View File
@@ -9,19 +9,39 @@
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<!-- #pageChrome : tout ce qui est en dehors de <main> mais doit quand même
changer d'une page à l'autre (barre du jeu comprise) — un seul bloc
stable pour que pjax.js puisse le remplacer d'un coup à chaque
navigation (voir swapDocument() dans pjax.js). Avant l'ajout de ce
conteneur, pjax.js cherchait un <header class="topbar"> qui n'a jamais
existé (la barre est un <nav>, pas un <header>) : la barre de
navigation du jeu (ajoutée après pjax.js) ne changeait donc jamais
pendant la navigation pjax — absente en arrivant sur un jeu tant qu'on
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
laissée en place en revenant sur l'accueil (liste des jeux) où elle
n'a pourtant rien à faire. -->
<div id="pageChrome">
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
</div>
</nav>
{% block breadcrumb_wrap %}
<nav class="breadcrumb breadcrumbBar" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('index') }}">🛠️ Mes jeux</a></li>
{% block breadcrumb %}{% endblock %}
</ul>
{% if game is defined and game %}
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
Écrans/Éléments de jeu/Variables/Objets n'ont plus leur propre page —
tout ça vit désormais dans les onglets du tableau de bord (voir
game_dashboard.html) — donc un seul lien y mène. Remplace le fil
d'Ariane (retiré, devenu redondant) : n'apparaît que si la route a mis
un `game` dans le contexte du template (déjà fait par toutes les
pages d'un jeu) — absente sur l'accueil (liste des jeux). "Jouer" est
délibérément le DERNIER lien — une action à part (ouvre l'aperçu
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
<nav class="gameNavBar" aria-label="Navigation du jeu">
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
</nav>
{% endblock %}
{% endif %}
</div>
<main class="content {% block content_class %}{% endblock %}">
{% block content %}{% endblock %}
</main>
-75
View File
@@ -1,75 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'Modifier' if row else 'Ajouter' }} — {{ definition.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">{{ definition.name }}</a></li>
<li class="is-active"><a>{{ 'Modifier #' ~ row.id if row else 'Nouvelle entrée' }}</a></li>
{% endblock %}
{% block content %}
<h1 class="title">{{ 'Modifier l\'entrée #' ~ row.id if row else 'Ajouter une entrée' }} — {{ definition.name }}</h1>
<form method="post" class="formPassthrough">
<div class="twoCol">
<div class="colLeft">
<h2>Objet</h2>
<div class="card">
<p class="hint">Formulaire généré automatiquement à partir de la définition de « {{ definition.name }} » — aucun champ n'est codé en dur ici.</p>
<div class="actionsRow">
<button type="submit" class="primary">{{ 'Enregistrer les modifications' if row else 'Enregistrer en base' }}</button>
</div>
<p class="hint" style="margin-top:10px;"><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">← Retour à « {{ definition.name }} »</a></p>
</div>
</div>
<div class="colRight">
<h2>Champs</h2>
<div class="fieldsTableWrap" style="padding:10px;">
{% for f in definition.fields %}
{% set col = f.name|colname %}
{% set current = row[col] if row else none %}
<div class="field">
<label class="label" for="field_{{ f.id }}">{{ f.name }}{% if f.required %} *{% endif %}</label>
<div class="control">
{% if f.type == 'texte' %}
<input class="input" type="text" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'texte_long' %}
<textarea class="textarea" id="field_{{ f.id }}" name="{{ col }}" rows="4" {{ 'required' if f.required else '' }}>{{ current or '' }}</textarea>
{% elif f.type == 'nombre_entier' %}
<input class="input" type="number" step="1" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'nombre_decimal' %}
<input class="input" type="number" step="any" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'booleen' %}
<label class="checkbox"><input type="checkbox" id="field_{{ f.id }}" name="{{ col }}" {{ 'checked' if current else '' }}> Oui</label>
{% elif f.type == 'date' %}
<input class="input" type="date" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'relation' %}
{% set current_rel = row[col ~ '_id'] if row else none %}
<div class="select is-fullwidth">
<select id="field_{{ f.id }}" name="{{ col }}_id" {{ 'required' if f.required else '' }}>
<option value="">—</option>
{% for opt in relation_options.get(f.id, []) %}
<option value="{{ opt.id }}" {{ 'selected' if current_rel == opt.id else '' }}>{{ opt.label }}</option>
{% endfor %}
</select>
</div>
{% if not relation_options.get(f.id) %}
<p class="help">Aucune entrée n'existe encore pour l'objet lié — crée-en une d'abord si tu veux pouvoir la sélectionner ici.</p>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</form>
{% endblock %}
-128
View File
@@ -1,128 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ definition.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li class="is-active"><a>{{ definition.name }}</a></li>
{% endblock %}
{% block content %}
<h1 class="title">{{ definition.name }}
<a class="button is-small" href="{{ url_for('object_edit', slug=game.slug, definition_id=definition.id) }}" title="Modifier cet objet">✏️</a>
</h1>
<p class="hint mb-4">Table SQL : {{ definition.table_name }} — {{ definition.fields|length }} champ(s)</p>
{% if referenced_by %}
<div class="warningBanner">
⚠️ D'autres objets ont une relation vers « {{ definition.name }} » :
{{ referenced_by|map(attribute='name')|join(', ') }}.
La suppression de cet objet est bloquée tant que ces relations existent.
</div>
{% endif %}
{% if blocked_row_id %}
<div class="warningBanner">
⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée par
{% for ref in blocked_row_refs %}{{ ref.count }} entrée(s) de « {{ ref.definition_name }} » (champ « {{ ref.field_name }} »){% if not loop.last %}, {% endif %}{% endfor %}.
Modifie ou supprime d'abord ces entrées.
</div>
{% endif %}
<div class="twoCol">
<div class="colLeft">
<h2>Ajouter</h2>
<div class="card">
<p class="hint">Une entrée, c'est une ligne de la table SQL « {{ definition.table_name }} ».</p>
<div class="actionsRow">
<a class="button is-primary is-outlined is-fullwidth" href="{{ url_for('data_new', slug=game.slug, definition_id=definition.id) }}">+ Ajouter une entrée</a>
</div>
</div>
</div>
<div class="colRight">
<h2>Données enregistrées</h2>
{% if rows %}
{% macro row_field_display(f, r) %}
{%- if f.type == 'relation' -%}
{{ relation_labels.get(f.id, {}).get(r[(f.name|colname) ~ '_id'], '—') }}
{%- elif f.type == 'booleen' -%}
{{ 'Oui' if r[f.name|colname] else 'Non' }}
{%- else -%}
{{ r[f.name|colname] }}
{%- endif -%}
{% endmacro %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable dataTable fieldsTable">
<thead>
<tr>
<th>#</th>
{% for f in definition.fields %}<th>{{ f.name }}</th>{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
{% for f in definition.fields %}
<td>{{ row_field_display(f, r) }}</td>
{% endfor %}
<td>
<div class="buttons is-inline-flex are-small">
<button type="button" class="button is-small" title="Voir cette entrée en détail" onclick="openRowDetail({{ r.id }})">👁️</button>
<a class="button is-small" href="{{ url_for('data_edit', slug=game.slug, definition_id=definition.id, row_id=r.id) }}">✏️</a>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=definition.id, row_id=r.id) }}"
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for r in rows %}
<div class="modal" id="rowDetail{{ r.id }}">
<div class="modal-background" onclick="closeRowDetail({{ r.id }})"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Entrée #{{ r.id }}</p>
<button class="delete" aria-label="Fermer" onclick="closeRowDetail({{ r.id }})"></button>
</header>
<section class="modal-card-body">
{% for f in definition.fields %}
<div class="rowDetailField">
<div class="rowDetailLabel">{{ f.name }}</div>
<div class="rowDetailValue">{{ row_field_display(f, r) }}</div>
</div>
{% endfor %}
</section>
</div>
</div>
{% endfor %}
{% else %}
<p class="hint">Aucune donnée enregistrée pour l'instant.</p>
{% endif %}
</div>
</div>
<script>
function openRowDetail(id) {
var modal = document.getElementById('rowDetail' + id);
if (modal) modal.classList.add('is-active');
}
function closeRowDetail(id) {
var modal = document.getElementById('rowDetail' + id);
if (modal) modal.classList.remove('is-active');
}
if (!window.__forgeRowDetailEscBound) {
window.__forgeRowDetailEscBound = true;
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
document.querySelectorAll('.modal.is-active').forEach(function (m) { m.classList.remove('is-active'); });
});
}
</script>
{% endblock %}
-73
View File
@@ -1,73 +0,0 @@
{% extends "base.html" %}
{% block title %}Éléments de jeu — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li class="is-active"><a>Éléments de jeu</a></li>
{% endblock %}
{% block content %}
<h1 class="title">Éléments de jeu</h1>
<p class="hint mb-4">Un élément de jeu est un conteneur réutilisable.</p>
<div class="twoCol">
<div class="colLeft">
<h2>Créer</h2>
<form method="post" class="card">
<div class="fieldRow">
<div class="formField" style="flex:0 0 80px;">
<label for="icon">Icône</label>
<input type="text" id="icon" name="icon" value="🧩" maxlength="4">
</div>
<div class="formField" style="flex:1;">
<label for="name">Nom</label>
<input type="text" id="name" name="name" placeholder="ex. Personnage, Outil, Carte mail..." required>
</div>
</div>
<div class="formField">
<label for="bound_definition_id">Objet lié (optionnel)</label>
<select id="bound_definition_id" name="bound_definition_id">
<option value="">(aucun — élément décoratif, sans donnée)</option>
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="actionsRow"><button type="submit" class="primary">+ Créer</button></div>
</form>
</div>
<div class="colRight">
<h2>Catalogue</h2>
{% if types %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Usage</th><th></th></tr></thead>
<tbody>
{% for t in types %}
<tr>
<td>{{ t.icon }} {{ t.name }}</td>
<td class="has-text-grey">
{{ usage.get(t.id, 0) }} instance(s)
{% if t.bound_definition_id %}
{% for d in definitions %}{% if d.id == t.bound_definition_id %} · « {{ d.name }} »{% endif %}{% endfor %}
{% endif %}
</td>
<td class="fieldsTableActions">
<a class="button is-small" href="{{ url_for('screen_edit', slug=game.slug, screen_id=t.template_screen_id) }}" title="Modifier le contenu de cet élément de jeu">✏️</a>
<form method="post" action="{{ url_for('element_type_delete', slug=game.slug, element_type_id=t.id) }}"
onsubmit="return confirm('Supprimer l\'élément de jeu « {{ t.name }} » du catalogue (et tout son contenu) ?');">
<button type="submit" class="button is-small is-danger" {{ 'disabled title="Utilisé sur un écran — retire-le des écrans d\'abord"' if usage.get(t.id, 0) > 0 else '' }}>🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun élément de jeu défini pour l'instant.</p>
{% endif %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
-75
View File
@@ -1,75 +0,0 @@
{% extends "base.html" %}
{% block title %}Variables — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li class="is-active"><a>Variables</a></li>
{% endblock %}
{% block content %}
<h1 class="title">Variables globales</h1>
<p class="hint mb-4">Des propriétés/valeurs communes à tout le jeu (pas attachées à un objet de données précis) — utile par exemple pour une porte ouverte ou non, un niveau débloqué... Une variable peut être lue dans une "Condition de visibilité" (propriétés d'un élément) et modifiée par une action "Modifier une variable" (Logique de la scène).</p>
<div class="twoCol">
<div class="colLeft">
<h2>Créer</h2>
<form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="card">
<div class="formField">
<label for="new-var-name">Nom</label>
<input type="text" id="new-var-name" name="name" placeholder="ex. porte_ouverte" required autocomplete="off">
</div>
<div class="formField">
<label for="new-var-type">Type</label>
<select id="new-var-type" name="type">
{% for key, val in variable_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
</div>
<div class="formField">
<label for="new-var-value">Valeur de départ</label>
<input type="text" id="new-var-value" name="default_value" placeholder="ex. 0" autocomplete="off">
</div>
<div class="actionsRow"><button type="submit" class="primary">+ Créer la variable</button></div>
</form>
</div>
<div class="colRight">
<h2>Variables définies</h2>
{% if variables %}
{% for v in variables %}
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.id }}"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th></th></tr></thead>
<tbody>
{% for v in variables %}
<tr>
<td>{{ v.name }}</td>
<td>
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
{% for key, val in variable_types.items() %}
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ val.label }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" aria-label="Valeur de « {{ v.name }} »"></td>
<td class="fieldsTableActions">
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small">Enregistrer</button>
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
onsubmit="return confirm('Supprimer la variable « {{ v.name }} » ? Toute condition de visibilité ou action qui la référence cessera de fonctionner.');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucune variable pour l'instant — crée la première à gauche.</p>
{% endif %}
</div>
</div>
{% endblock %}
-1
View File
@@ -1,6 +1,5 @@
{% extends "base.html" %}
{% block title %}Forge Engine — Mes jeux{% endblock %}
{% block breadcrumb_wrap %}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content %}
-194
View File
@@ -1,194 +0,0 @@
{% extends "base.html" %}
{% block title %}Modifier {{ definition.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">{{ definition.name }}</a></li>
<li class="is-active"><a>Modifier</a></li>
{% endblock %}
{% block content %}
<h1 class="title">Modifier l'objet « {{ definition.name }} »</h1>
<p class="help mb-4">Table SQL : {{ definition.table_name }}</p>
<form method="post" class="field has-addons">
<div class="control is-expanded">
<label class="label" for="object_name">Nom de l'objet</label>
<input class="input" type="text" id="object_name" name="object_name" value="{{ definition.name }}" required>
</div>
<div class="control" style="align-self:flex-end;">
<button type="submit" class="button">Renommer</button>
</div>
</form>
<div class="twoCol">
<!-- Colonne de gauche : ajouter un champ + supprimer l'objet -->
<div class="colLeft">
<h2 class="subtitle">Ajouter un champ</h2>
<form method="post" action="{{ url_for('object_field_add', slug=game.slug, definition_id=definition.id) }}" class="box" id="addFieldForm">
<div class="field">
<label class="label">Nom du champ</label>
<div class="control"><input class="input" type="text" name="field_name" placeholder="ex. nom, description, pv..." required></div>
</div>
<div class="field">
<label class="label">Type</label>
<div class="control">
<div class="select is-fullwidth">
<select name="field_type" id="newFieldType">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div id="newFieldRelationWrap" class="field" style="display:none;">
<label class="label">Objet lié</label>
<div class="control">
<div class="select is-fullwidth">
<select name="field_relation">
{% for d in existing_definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% if not existing_definitions %}
<p class="help">Aucun autre objet défini.</p>
{% endif %}
</div>
<div id="newFieldBoundsWrap" class="columns" style="display:none;">
<div class="column">
<div class="field">
<label class="label">Valeur mini (optionnel)</label>
<div class="control"><input class="input" type="number" step="any" name="field_min" placeholder="ex. 0"></div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Valeur maxi (optionnel)</label>
<div class="control"><input class="input" type="number" step="any" name="field_max" placeholder="ex. 100"></div>
</div>
</div>
</div>
<div class="field">
<label class="label">Obligatoire ?</label>
<div class="control">
<div class="select is-fullwidth">
<select name="field_required">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
</div>
</div>
</div>
<div class="field"><button type="submit" class="button is-primary">+ Ajouter ce champ</button></div>
</form>
<div class="box has-background-danger-dark mt-4">
<h3 class="title is-6 has-text-danger-light">Supprimer cet objet</h3>
<form method="post" action="{{ url_for('object_delete', slug=game.slug, definition_id=definition.id) }}"
onsubmit="return confirm('Supprimer définitivement l\'objet « {{ definition.name }} », sa table et toutes ses données ? Cette action est irréversible.');">
<button type="submit" class="button is-danger">🗑️ Supprimer l'objet « {{ definition.name }} »</button>
</form>
</div>
</div>
<!-- Colonne de droite : champs existants, chacun modifiable -->
<div class="colRight">
<h2 class="subtitle">Champs existants</h2>
{% if definition.fields %}
{% for f in definition.fields %}
<form method="post" action="{{ url_for('object_field_edit', slug=game.slug, definition_id=definition.id, field_id=f.id) }}" id="fieldEditForm{{ f.id }}"></form>
<form method="post" action="{{ url_for('object_field_delete', slug=game.slug, definition_id=definition.id, field_id=f.id) }}" id="fieldDeleteForm{{ f.id }}"
onsubmit="return confirm('Retirer le champ « {{ f.name }} » ? Les données déjà enregistrées dans ce champ seront perdues.');"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead>
<tr>
<th>Nom du champ</th>
<th>Type</th>
<th>Objet lié</th>
<th>Mini</th>
<th>Maxi</th>
<th>Obligatoire ?</th>
<th></th>
</tr>
</thead>
<tbody>
{% for f in definition.fields %}
<tr>
<td><input class="input is-small" type="text" form="fieldEditForm{{ f.id }}" name="field_name" value="{{ f.name }}" aria-label="Nom du champ « {{ f.name }} »" required></td>
<td>{{ field_types.get(f.type, {}).get('label', f.type) }}</td>
<td>
{% if f.type == 'relation' %}
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_relation" aria-label="Objet lié pour « {{ f.name }} »">
{% for d in existing_definitions %}
<option value="{{ d.id }}" {{ 'selected' if d.id == f.relation_definition_id else '' }}>{{ d.name }}</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_min" value="{{ f.min_value if f.min_value is not none else '' }}" placeholder="ex. 0" aria-label="Valeur mini pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_max" value="{{ f.max_value if f.max_value is not none else '' }}" placeholder="ex. 100" aria-label="Valeur maxi pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_required" aria-label="« {{ f.name }} » obligatoire ?">
<option value="0" {{ 'selected' if not f.required else '' }}>Non</option>
<option value="1" {{ 'selected' if f.required else '' }}>Oui</option>
</select>
</div>
</td>
<td class="fieldsTableActions">
<button type="submit" form="fieldEditForm{{ f.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="fieldDeleteForm{{ f.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="help">Mini/Maxi (bornage automatique) : laisser vide = pas de limite. S'applique à "Donner cette valeur", "Augmenter de..." et "Diminuer de..." dans le graphe de logique.</p>
{% else %}
<p class="help">Aucun champ pour l'instant.</p>
{% endif %}
</div>
</div>
<script>
(function(){
var sel = document.getElementById('newFieldType');
var relWrap = document.getElementById('newFieldRelationWrap');
var boundsWrap = document.getElementById('newFieldBoundsWrap');
var NUMERIC_TYPES = ['nombre_entier', 'nombre_decimal'];
function sync(){
relWrap.style.display = sel.value === 'relation' ? 'block' : 'none';
boundsWrap.style.display = NUMERIC_TYPES.indexOf(sel.value) !== -1 ? 'flex' : 'none';
}
sel.addEventListener('change', sync);
sync();
})();
</script>
{% endblock %}
-104
View File
@@ -1,104 +0,0 @@
{% extends "base.html" %}
{% block title %}Nouvel objet — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li class="is-active"><a>Nouvel objet</a></li>
{% endblock %}
{% block content %}
<h1 class="title">Définir un nouvel objet</h1>
<form method="post" id="objectForm" class="formPassthrough">
<div class="twoCol">
<div class="colLeft">
<h2>Objet</h2>
<div class="card">
<div class="formField">
<label for="object_name">Nom de l'objet (ex. Personnage, Niveau, Question...)</label>
<input type="text" id="object_name" name="object_name" required>
</div>
<div class="actionsRow"><button type="submit" class="primary">Créer l'objet</button></div>
</div>
</div>
<div class="colRight">
<h2>Champs</h2>
<button type="button" class="button is-outlined is-fullwidth mb-2" id="btnAddField">+ Ajouter un champ</button>
<div class="fieldsTableWrap" style="padding:10px;">
<div id="fieldsList"></div>
</div>
</div>
</div>
</form>
<template id="fieldRowTemplate">
<div class="fieldRowCard box">
<div class="columns">
<div class="column">
<div class="field">
<label class="label">Nom du champ</label>
<div class="control"><input class="input" type="text" name="field_name[]" placeholder="ex. nom, description, pv..." required></div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Type</label>
<div class="control">
<div class="select is-fullwidth">
<select name="field_type[]" class="fieldTypeSelect">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
<div class="relationTarget field" style="display:none;">
<label class="label">Objet lié</label>
<div class="control">
<div class="select is-fullwidth">
<select name="field_relation[]">
{% for d in existing_definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% if not existing_definitions %}
<p class="help">Aucun autre objet défini.</p>
{% endif %}
</div>
<div class="boundsTarget columns" style="display:none;">
<div class="column">
<div class="field">
<label class="label">Valeur mini</label>
<div class="control"><input class="input" type="number" step="any" name="field_min[]" placeholder="ex. 0"></div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Valeur maxi</label>
<div class="control"><input class="input" type="number" step="any" name="field_max[]" placeholder="ex. 100"></div>
</div>
</div>
</div>
<div class="field">
<label class="label">Obligatoire ?</label>
<div class="control">
<div class="select is-fullwidth">
<select name="field_required[]">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
</div>
</div>
</div>
<button type="button" class="button is-small is-danger btnRemoveField">🗑️ Retirer ce champ</button>
</div>
</template>
<script src="{{ url_for('static', filename='object_form.js') }}"></script>
{% endblock %}
+279 -80
View File
@@ -163,33 +163,69 @@
}
function applyAnimationClip(clip) {
var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]');
if (!target) return;
// 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';
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);
});
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) {
@@ -200,7 +236,7 @@
// suite sur le même écran avant la fin de sa timeline.
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
pendingAnimationTimers = [];
var clips = (gameData.animations || {})[String(screenId)] || [];
var clips = collectAnimationClips(screenId);
clips.forEach(function(clip) {
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
pendingAnimationTimers.push(t);
@@ -233,11 +269,20 @@
// 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 du
// 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 findTriggerNode(elementId, event) {
for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey];
const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event);
if (found) return found;
if (found) return { node: found, screenId: screenIdKey };
}
return null;
}
@@ -290,7 +335,7 @@
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;
runFlowFrom(trigger.id);
runFlowFrom(trigger.node.id, trigger.screenId);
});
}
});
@@ -311,8 +356,8 @@
const leaveTrigger = findTriggerNode(elementId, 'fin_survol');
if (!enterTrigger && !leaveTrigger) return;
elDiv.dataset.hoverTriggerBound = '1';
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.id));
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id));
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId));
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId));
});
}
@@ -339,46 +384,124 @@
const screenData = screensData.find(s => s.id === window.currentScreenId);
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
if (!row || !screenData || !screenDiv) return;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (!wrapper || !el.rendered_html || el.rendered_html.indexOf(PLACEHOLDER_OPEN) === -1) return;
// Avant de regénérer ce sous-arbre depuis le HTML D'ORIGINE (nécessaire
// pour ne pas laisser les valeurs d'un mail précédent affichées, voir
// plus haut), sauvegarde l'état visuel TRANSITOIRE posé par une action
// "Modifier un élément" (jamais enregistré en base — voir
// applyElementProperty) sur cet élément ou l'un de ses descendants,
// pour le réappliquer après. Sans ça, un panneau rendu visible par
// cette action (ex. "mail content") redevenait invisible à CHAQUE
// rafraîchissement de données, y compris pour un changement de donnée
// sans rapport avec lui — puisque wrapper.innerHTML = html ci-dessous
// recrée entièrement ses nœuds à partir de leur état PAR DÉFAUT.
const savedState = {};
wrapper.querySelectorAll('[data-element-id]').forEach(function(node) {
savedState[node.dataset.elementId] = {
style: node.getAttribute('style') || '',
className: node.className,
dataset: Object.assign({}, node.dataset),
};
});
// 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); });
});
let html = el.rendered_html;
Object.keys(row).forEach(function(fieldName) {
if (fieldName === 'id') return;
const value = row[fieldName] == null ? '' : String(row[fieldName]);
const tmp = document.createElement('div');
tmp.textContent = value;
html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML);
});
wrapper.innerHTML = html;
// 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');
wrapper.querySelectorAll('[data-element-id]').forEach(function(node) {
const saved = savedState[node.dataset.elementId];
if (!saved) return;
node.setAttribute('style', saved.style);
node.className = saved.className;
Object.assign(node.dataset, saved.dataset);
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);
});
}
@@ -399,21 +522,78 @@
screensData = gameData.screens;
const screenData = screensData.find(s => s.id === window.currentScreenId);
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
if (!screenData || !screenDiv) return;
screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) {
if (!el.rendered_html) return;
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
// à une donnée) et "visibilityGated" (condition de visibilité —
// 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.
if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return;
const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner');
if (wrapper) wrapper.innerHTML = el.rendered_html;
});
applyOpenRowBindings();
// 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) et "visibilityGated" (condition de visibilité —
// 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));
}
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 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) target.replaceWith(newNode);
});
applyOpenRowBindings();
} catch (err) {
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
}
}
bindClicks();
bindHoverTexts();
bindHoverTriggers();
@@ -533,6 +713,18 @@
}
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 + '"]');
@@ -672,8 +864,15 @@
// 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.
function runFlowFrom(nodeId) {
const flow = gameData.flows[String(window.currentScreenId)];
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode)
// 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é (findTriggerNode
// 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; });
+29 -22
View File
@@ -6,10 +6,6 @@
<link rel="stylesheet" href="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
{% endblock %}
<!-- Pas de fil d'Ariane sur cette page : le canevas doit être monté le
plus haut possible (voir .content-builder), et le nom de l'écran est
de toute façon déjà éditable juste au-dessus (panneau "Éléments"). -->
{% block breadcrumb_wrap %}{% endblock %}
{% block content %}
{% if screen.is_template %}
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;">
@@ -145,7 +141,7 @@
{% endfor %}
</div>
{% else %}
<p class="hint">Pas encore d'élément de jeu personnalisé — <a href="{{ url_for('element_types', slug=game.slug) }}">crées-en un</a>.</p>
<p class="hint">Pas encore d'élément de jeu personnalisé — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='element_types') }}">crées-en un</a>.</p>
{% endif %}
</div>
</details>
@@ -160,20 +156,23 @@
<!-- Centre : l'écran (onglet "Écran"), ou la logique de la scène / la
timeline d'animation quand leur onglet est actif — un seul onglet
visible à la fois, chacun occupant tout l'espace disponible (voir
switchBuilderTab() plus bas). Absents sur un écran-modèle (élément
de jeu) : ses déclencheurs référenceraient des éléments dont l'id
change à chaque copie (voir instantiate_template_tree), donc une
logique/animation posée ici ne fonctionnerait plus une fois
l'élément réutilisé — seul l'onglet "Écran" a alors un sens. -->
switchBuilderTab() plus bas). Présents aussi sur un écran-modèle
(élément de jeu réutilisable, ex. "mail content") : ses enfants
gardent désormais TOUJOURS le même id, à chaque exemplaire posé sur
n'importe quelle scène (rechargés en direct depuis ce modèle à
chaque affichage, voir _render_element_type_children côté serveur —
l'ancien mécanisme qui les copiait avec un nouvel id à chaque pose,
instantiate_template_tree, a été retiré). Un déclencheur/une
animation posé ici, sur un enfant du modèle, s'exécute donc bien
partout où ce modèle est utilisé (voir findTriggerNode/
collectAnimationClips dans templates/play.html). -->
<div class="builderCanvasArea">
{% set current_aspect = screen.preview_aspect or '9/16' %}
{% if not screen.is_template %}
<div class="builderTabs" id="builderTabs">
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
</div>
{% endif %}
<div class="builderTabPanel active" data-tab-panel="screen">
<div class="canvasFrame">
@@ -190,7 +189,6 @@
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
</div>
{% if not screen.is_template %}
<div class="builderTabPanel" id="flowTabPanel" data-tab-panel="flow">
<div class="flowToolbar">
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
@@ -222,7 +220,7 @@
<div id="nodeFormCondition" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
{% else %}
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
@@ -315,7 +313,7 @@
<div id="nf-action-data-fields" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
{% else %}
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
{% for d in definitions %}
@@ -449,7 +447,6 @@
<div id="animTracks" class="animTracks"></div>
</div>
</div>
{% endif %}
</div>
<!-- Panneau droit : propriétés de l'élément sélectionné — flottant,
@@ -649,7 +646,7 @@
{% set ns.started = True %}
{% endif %}
<div class="field controlRow{{ ' visCondRow' if c.key.startswith('visibilite_cond_') and c.key != 'visibilite_cond_mode' else '' }}"
{% if c.key == 'visibilite_cond_variable' %}data-vis-cond-scope="variable"
{% if c.key in ('visibilite_cond_variable', 'visibilite_cond_variable_chemin') %}data-vis-cond-scope="variable"
{% elif c.key in ('visibilite_cond_definition_id', 'visibilite_cond_champ') %}data-vis-cond-scope="objet"
{% elif c.key in ('visibilite_cond_operateur', 'visibilite_cond_valeur') %}data-vis-cond-scope="any"
{% endif %}>
@@ -714,8 +711,12 @@
{% endfor %}
</select>
</div>
<!-- Utile seulement si la variable choisie est de type
"objet"/"tableau" : sans chemin, c'est toute la variable
(son JSON) qui est utilisée comme valeur de comparaison. -->
<input class="input is-small filterValueVarPath" type="text" placeholder="Chemin optionnel (ex. .nom ou [0].valeur)" style="margin-top:6px;" oninput="updateFilterValueFromVariable(this.closest('.filterValueControl'))">
{% if not global_variables %}
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_variables', slug=game.slug) }}" target="_blank">crées-en une ↗</a></p>
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
{% endif %}
</div>
</div>
@@ -811,7 +812,7 @@
</div>
</div>
{% if c.key == 'visibilite_cond_variable' %}
<p class="help">Pas la variable qu'il te faut ? <a href="{{ url_for('game_variables', slug=game.slug) }}" target="_blank">Gérer les variables du jeu ↗</a></p>
<p class="help">Pas la variable qu'il te faut ? <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">Gérer les variables du jeu ↗</a></p>
{% endif %}
{% endif %}
{% endif %}
@@ -1717,8 +1718,10 @@ 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").
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}]+)\\s*\\}\\}$');
// 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) + '"]');
@@ -1742,8 +1745,10 @@ function updateFilterValueFromDynamic(wrap) {
function updateFilterValueFromVariable(wrap) {
var varSel = wrap.querySelector('.filterValueVarSel');
var pathInput = wrap.querySelector('.filterValueVarPath');
var input = wrap.querySelector('.filterValueFixe input');
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + _FILTER_REF_CLOSE) : '';
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 }));
}
@@ -1779,6 +1784,7 @@ function initFilterValuePickers() {
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);
@@ -1787,6 +1793,7 @@ function initFilterValuePickers() {
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';
-62
View File
@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block title %}Écrans — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li class="is-active"><a>Écrans</a></li>
{% endblock %}
{% block content %}
<h1 class="title">Écrans du jeu</h1>
{% if request.args.get('blocked_screen') %}
<div class="warningBanner">
⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action.
</div>
{% endif %}
<div class="twoCol">
<div class="colLeft">
<h2>Créer</h2>
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="card">
<div class="formField">
<label for="name">Nom du nouvel écran</label>
<input type="text" id="name" name="name" placeholder="ex. Accueil, Formulaire d'inscription, Scène 1..." required>
</div>
<div class="actionsRow"><button type="submit" class="primary">+ Créer l'écran</button></div>
</form>
</div>
<div class="colRight">
<h2>Enchaînement des écrans</h2>
{% if screen_list %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>#</th><th>Nom</th><th></th></tr></thead>
<tbody>
{% for s in screen_list %}
<tr>
<td>{{ loop.index }}</td>
<td>
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">{{ s.name }}</a>
{% if loop.first %} <span class="tag is-info is-light">★ Départ</span>{% endif %}
</td>
<td class="fieldsTableActions">
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="up"><button type="submit" class="button is-small" {{ 'disabled' if loop.first else '' }}>↑</button></form>
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="down"><button type="submit" class="button is-small" {{ 'disabled' if loop.last else '' }}>↓</button></form>
<a class="button is-small" href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">✏️</a>
<form method="post" action="{{ url_for('screen_delete', slug=game.slug, screen_id=s.id) }}"
onsubmit="return confirm('Supprimer l\'écran « {{ s.name }} » et tous ses éléments ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun écran pour l'instant — crée le premier à gauche.</p>
{% endif %}
</div>
</div>
{% endblock %}
+7 -5
View File
@@ -100,15 +100,17 @@ def test_element_type_template_case_a_and_case_b(client, game, mail_object):
follow_redirects=False,
)
assert resp.status_code == 302
html = client.get(f"/game/{slug}/element-types").data.decode()
html = client.get(f"/game/{slug}").data.decode()
m = re.search(r"/screens/(\d+)/edit", html)
assert m, "le lien vers l'écran-modèle doit apparaître dans le catalogue"
template_screen_id = int(m.group(1))
# L'écran-modèle est un écran caché (is_template) : absent de la liste
# normale des écrans jouables.
screens_html = client.get(f"/game/{slug}/screens").data.decode()
assert "CarteMail" not in screens_html or "Modèle" not in screens_html
# normale des écrans jouables (onglet "Écrans du jeu" du tableau de
# bord) — son nom complet ("Modèle : CarteMail") ne doit donc apparaître
# nulle part sur cette page, contrairement à "CarteMail" seul (le nom de
# l'élément de jeu lui-même, affiché dans l'onglet "Éléments de jeu").
assert "Modèle : CarteMail" not in html
# Construit le contenu du modèle : un titre lié au champ "sujet".
resp = client.post(
@@ -266,7 +268,7 @@ def test_screen_editor_property_panels_closed_by_default(client, game):
def test_delete_element_type_removes_hidden_template_screen(client, game):
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
html = client.get(f"/game/{game}/element-types").data.decode()
html = client.get(f"/game/{game}").data.decode()
m = re.search(r"/screens/(\d+)/edit", html)
template_screen_id = int(m.group(1))
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
+121
View File
@@ -0,0 +1,121 @@
"""Variables globales de type "objet"/"tableau" (db/constants.py) : la
valeur est du JSON, lisible via un chemin ".champ"/"[index]" chaînable
(_resolve_variable_path, screens/rendering/filter_repeater_rows.py) —
aussi bien dans un filtre de Répéteur ({{$var.chemin}}) que dans une
Condition de visibilité en mode "variable" (contrôle séparé
"visibilite_cond_variable_chemin")."""
import json
import re
import db
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_element(client, slug, screen_id, widget="texte"):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def _rendered_html(client, slug):
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
return payload["screens"][0]["elements"][0]["rendered_html"]
def _create_niveau_object(client, slug):
resp = client.post(f"/game/{slug}/objects/new", data={
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "12"})
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "99"})
return def_id
def test_create_and_update_validate_json_with_safe_fallback(client, game):
client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet", "default_value": "not json"})
variable = db.get_global_variable(game, "perso")
assert variable["value"] == "{}" # JSON invalide -> défaut sûr, pas la chaîne brute
client.post(f"/game/{game}/variables/new", data={"name": "scores", "type": "tableau", "default_value": ""})
variable = db.get_global_variable(game, "scores")
assert variable["value"] == "[]" # vide -> défaut sûr propre au type
db.update_global_variable(game, db.get_global_variable(game, "perso")["id"], "objet", json.dumps({"nom": "Alice"}))
assert json.loads(db.get_global_variable(game, "perso")["value"]) == {"nom": "Alice"}
def test_repeater_filter_reads_a_field_inside_an_object_variable(client, game):
client.post(f"/game/{game}/variables/new", data={
"name": "perso", "type": "objet",
"default_value": json.dumps({"nom": "Alice", "arme": {"degats": 12}}),
})
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
# {{$perso.arme.degats}} (chemin chaîné) doit résoudre à 12, et donc ne
# garder que la ligne "numero = 12".
client.post(f"/game/{game}/elements/{rep_id}/save", data={
"ctrl_definition_id": str(def_id),
"ctrl_filtre_champ": "numero",
"ctrl_filtre_operateur": "egal",
"ctrl_filtre_valeur": "{{$perso.arme.degats}}",
})
html = _rendered_html(client, game)
assert html.count("repeaterItem") == 1
assert 'data-row-id="1"' in html
assert 'data-row-id="2"' not in html
def test_visibility_condition_reads_an_array_element_by_index(client, game):
client.post(f"/game/{game}/variables/new", data={
"name": "scores", "type": "tableau", "default_value": json.dumps([10, 20, 30]),
})
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id)
# scores[1] == 20 -> visible.
client.post(f"/game/{game}/elements/{element_id}/save", data={
"ctrl_visibilite_cond_mode": "variable",
"ctrl_visibilite_cond_variable": "scores",
"ctrl_visibilite_cond_variable_chemin": "[1]",
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": "20",
})
html = _rendered_html(client, game)
assert "display:none" not in html
# scores[0] == 10, pas 20 -> masqué.
client.post(f"/game/{game}/elements/{element_id}/save", data={
"ctrl_visibilite_cond_mode": "variable",
"ctrl_visibilite_cond_variable": "scores",
"ctrl_visibilite_cond_variable_chemin": "[0]",
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": "20",
})
html = _rendered_html(client, game)
assert "display:none" in html
def test_invalid_or_missing_path_never_crashes(client, game):
client.post(f"/game/{game}/variables/new", data={
"name": "perso", "type": "objet", "default_value": json.dumps({"nom": "Alice"}),
})
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id)
client.post(f"/game/{game}/elements/{element_id}/save", data={
"ctrl_visibilite_cond_mode": "variable",
"ctrl_visibilite_cond_variable": "perso",
"ctrl_visibilite_cond_variable_chemin": ".champ_inexistant",
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": "peu importe",
})
resp = client.get(f"/game/{game}/runtime-payload")
assert resp.status_code == 200
assert "display:none" in resp.get_json()["screens"][0]["elements"][0]["rendered_html"]