commit 3f4ebc4527004dfc6e2582e3e0e42c2dac91d13a Author: william Date: Fri Aug 21 16:22:35 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..49f3fe60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitea +.gitignore +.pytest_cache +__pycache__ +*.pyc +.venv +venv +tests +projects +README.md +.env +.env.local +docker-compose.prod.yml diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d2e76925 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Ce fichier ne configure PAS l'application (elle ne lit aucune variable +# d'environnement) — il configure uniquement docker-compose.prod.yml sur le +# serveur de production. Copier en ".env" à côté de docker-compose.prod.yml +# sur le serveur et adapter les valeurs ; ne jamais committer ce ".env". + +# Adresse complète de l'image dans le registre Gitea, ex : +# gitea.exemple.com/mon-compte/forge-engine +REGISTRY_IMAGE= + +# Tag d'image à déployer (le pipeline CI le fixe généralement au commit SHA) +IMAGE_TAG=latest + +# Port exposé sur l'hôte de production +PORT=5050 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 00000000..a9adeaba --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Build and deploy + +on: + push: + branches: [main] + +# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : +# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) +# REGISTRY_IMAGE chemin complet de l'image (ex: gitea.exemple.com/mon-compte/forge-engine) +# REGISTRY_USER utilisateur pour "docker login" sur ce registre +# REGISTRY_TOKEN jeton d'accès (scope package:write) pour ce registre +# DEPLOY_HOST adresse du serveur de production +# DEPLOY_USER utilisateur SSH sur ce serveur +# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur +# DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine) +# +# Utilise directement docker/ssh/scp en ligne de commande plutôt que des +# actions du marketplace, pour ne pas dépendre de la disponibilité de +# github.com / du miroir gitea.com depuis le runner self-hosted. + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Connexion au registre d'images + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build de l'image + run: | + docker build \ + -t "${{ secrets.REGISTRY_IMAGE }}:${{ gitea.sha }}" \ + -t "${{ secrets.REGISTRY_IMAGE }}:latest" \ + . + + - name: Publication de l'image + run: | + docker push "${{ secrets.REGISTRY_IMAGE }}:${{ gitea.sha }}" + docker push "${{ secrets.REGISTRY_IMAGE }}:latest" + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Préparation de la clé SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Envoi du docker-compose.prod.yml sur le serveur + run: | + scp -i ~/.ssh/deploy_key docker-compose.prod.yml \ + "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/docker-compose.prod.yml" + + - name: Pull et redémarrage sur le serveur + run: | + ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" ' + set -e + cd "${{ secrets.DEPLOY_PATH }}" + echo "REGISTRY_IMAGE=${{ secrets.REGISTRY_IMAGE }}" > .env + echo "IMAGE_TAG=${{ gitea.sha }}" >> .env + docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}" + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + docker image prune -f + ' diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..78597e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Environnements virtuels +.venv/ +venv/ +env/ + +# Variables d'environnement / secrets locaux +.env +.env.local + +# Jeux créés par les utilisateurs (données runtime, pas du code) +projects/ + +# OS / éditeurs +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..097e4722 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt requirements-prod.txt ./ +RUN pip install --no-cache-dir -r requirements-prod.txt + +COPY . . + +RUN useradd --create-home forge && chown -R forge:forge /app +USER forge + +EXPOSE 5050 + +CMD ["gunicorn", "--bind", "0.0.0.0:5050", "--workers", "2", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..c8757852 --- /dev/null +++ b/README.md @@ -0,0 +1,748 @@ +# Forge Engine — prototype (lot de fonctionnalités n°1) + +Outil no-code : créer des jeux, définir leurs objets de données (comme des +tables de base de données, avec des champs typés et des relations entre +eux), et remplir ces données via des formulaires générés automatiquement. + +Python + SQLite + Flask. Chaque jeu créé a sa propre base de données réelle +(fichier `.db`), pas de simulation : définir un objet exécute un vrai +`CREATE TABLE`, remplir le formulaire exécute un vrai `INSERT`. + +## Lancer (pour tester maintenant, en ligne de commande) + +```bash +cd forge-engine +pip install -r requirements.txt +python3 app.py +``` + +Le navigateur s'ouvre automatiquement sur http://127.0.0.1:5050. + +*(Ceci est la version de développement. Comme convenu, une version finale +empaquetée — double-clic, sans terminal ni installation — sera produite une +fois que toutes les fonctionnalités prévues auront été ajoutées.)* + +## Ce qui est construit dans ce lot + +1. **Créer un jeu** : bouton sur la page d'accueil → crée + `projects//index.html` + `index.css` + `index.js` (reliés + entre eux), et `projects//game.db` (base SQLite dédiée). +2. **Définir un objet** (comme une table de base de données) : nom + liste + de champs typés (texte, texte long, nombre entier, nombre décimal, + oui/non, date, relation vers un autre objet déjà défini dans ce jeu). + Chaque objet défini devient une vraie table SQL (`obj_`), avec les + bonnes colonnes et, pour les relations, une vraie clé étrangère. +3. **Formulaire généré automatiquement** : une fois un objet défini, le + moteur lit sa définition et construit lui-même le formulaire de saisie + adapté (bon type de champ HTML selon le type choisi, menu déroulant + peuplé avec les entrées existantes pour les relations). Valider le + formulaire enregistre une vraie ligne dans la table SQL correspondante. +4. **CRUD complet** sur les trois niveaux : + - **Jeu** : renommer, supprimer (dossier + base de données). + - **Objet** (page "✏️ Modifier cet objet") : renommer, ajouter un champ + (vrai `ALTER TABLE ... ADD COLUMN`), retirer un champ (vrai + `ALTER TABLE ... DROP COLUMN`), supprimer l'objet entier (vrai + `DROP TABLE`) — **bloqué** si un autre objet a une relation vers + celui-ci, avec indication de quel(s) objet(s). + - **Donnée** (une ligne) : modifier, supprimer — **bloqué** si une autre + ligne pointe vers elle via une relation, avec indication de combien et + depuis quel objet. + +5. **Écrans de jeu** (`🖥️ Écrans` depuis le tableau de bord d'un jeu) — + éditeur façon Elementor/Bricks Builder, pensé pour quelqu'un qui ne + connaît ni le HTML ni le CSS : + - **3 panneaux** : à gauche, une grille d'icônes pour ajouter un élément + — aucun nom de balise HTML visible nulle part. Au centre, l'écran en + très grand (repères Portrait / Paysage / Carré, juste visuels — l'écran + réel du joueur reste toujours responsive). À droite, les propriétés de + l'élément sélectionné, groupées par thème et repliables comme un + accordéon (Position & taille, Contenu, Texte, Disposition, Espacement, + Bordure, Actions...) — un seul groupe utile ouvert par défaut, les + autres se déplient d'un clic sur leur titre, pour garder le panneau + lisible même avec beaucoup de réglages. + - **20 types d'éléments** : Texte, Titre, Bouton, Lien, Image (URL ou + fichier envoyé depuis ton ordinateur — l'envoi enregistre aussitôt + l'image, sans clic supplémentaire à faire pour la voir apparaître), + Vidéo (idem), Champ de + formulaire — texte / email / mot de passe, Case à cocher, Bouton radio, + Zone de texte, Liste déroulante, Groupe de champs, Liste à puces, Liste + numérotée, Tableau, Conteneur / décor, Séparateur, Élément de jeu du + catalogue, et le **Répéteur de données** (voir plus bas). + - **Un réglage = un seul champ adapté** : case à cocher pour "gras" ou + "obligatoire", curseur pour une taille/un arrondi/un espacement, pastille + de couleur, boutons ⬅️/⬛/➡️/☰ pour l'alignement — jamais de nom de + propriété CSS à taper. Chaque élément dispose maintenant, en plus de ses + réglages propres, d'un socle commun : **visibilité** (visible / masqué / + invisible-mais-garde-sa-place), **espacement** (marge intérieure et + extérieure), **bordure** (épaisseur, couleur, style), et pour les + conteneurs/listes/tableaux, une **disposition interne** façon flexbox + (empilement libre / en ligne / en colonne, avec l'espacement entre + éléments). Les textes ont en plus gras, italique, souligné, barré et un + choix de police (polices système + quelques Google Fonts). + - **Position & taille visibles et modifiables** : 4 champs (X, Y, + Largeur, Hauteur, en %) dans le panneau de droite, synchronisés avec le + glisser-déposer sur l'écran. + - **Glisser-déposer et redimensionnement à la souris**, exactement comme + avant : on peut vraiment attraper un élément et le faire glisser sur + l'écran (un simple clic sans glisser le sélectionne juste). Sans jamais + toucher à un fichier HTML/CSS : tout est stocké en base et généré à la + volée. + - **ID unique par élément** : chaque élément affiche son identifiant + (`elt-`) dans ses propriétés — attribué automatiquement, garanti + unique dans le jeu. + - **Enchaînement des écrans** : liste ordonnée (premier écran, deuxième + écran...), réorganisable (↑ / ↓). + - **🔀 Logique de la scène (éditeur de flow à nœuds)** : la logique d'un + écran (ce qui se passe au clic ou à la soumission d'un formulaire) ne + se règle plus dans les propriétés de chaque élément, mais dans un + panneau dédié, rétractable, en bas de l'éditeur d'écran — un vrai + canevas où l'on pose des **nœuds** et où on les relie par des fils, + dans l'esprit de Salesforce Flow ou d'Unity Visual Scripting, mais + appliqué directement aux éléments et aux objets de données déjà + définis dans le jeu : + - **Déclencheur** (contour bleu) : "Au clic sur..." ou "À la + soumission de..." un élément de l'écran — c'est le point de départ + du graphe. + - **Condition** (contour orange) : compare un champ d'une ligne d'un + objet à une valeur (égal, différent, supérieur, inférieur...), et + propose deux fils de sortie, **Vrai** et **Faux**, à relier chacun à + la suite du graphe qui doit s'exécuter dans ce cas. + - **Action** (contour vert) : les mêmes effets qu'avant — changer + d'écran, modifier un élément (visibilité, couleurs, taille), ou + modifier une donnée d'un objet (texte, nombre, Vrai/Faux, bascule, + +/- un montant). + Pour relier deux nœuds : clique le point de sortie du premier (côté + droit), puis le point d'entrée du second (côté gauche) — un fil + apparaît. Reclique un fil pour le supprimer. Chaque nœud peut être + glissé sur le canevas pour organiser le graphe, et supprimé avec sa + petite croix. Une action "modifier une donnée" met à jour la vraie + base de données du jeu, et tout Répéteur de données affichant cet + objet à l'écran se met aussitôt à jour tout seul, sans recharger la + page. + **Plusieurs actions pour un même déclencheur** : le point de sortie + d'un nœud (Déclencheur, ou une branche Vrai/Faux d'une Condition) peut + être relié à PLUSIEURS nœuds suivants — au clic, tous s'exécutent (pas + seulement le premier relié). + + **Effet "va-et-vient" (état à deux positions)** : pour une action + "Modifier un élément" sur une couleur, une largeur ou une hauteur, une + case à cocher "Va-et-vient" fait apparaître une deuxième valeur — le + 1er clic applique la 2de valeur, le 2e clic revient à la 1re, et ainsi + de suite indéfiniment (par exemple : couleur de fond blanche au + départ, bleue au clic, blanche au reclic...). Cet état est mémorisé + uniquement dans la page du joueur (pas en base) : il repart de zéro si + l'écran est rechargé. + + **Limites connues de cette première version** : un nœud ne peut pas + être modifié une fois créé (il faut le supprimer et en recréer un avec + les bons réglages) ; une condition compare toujours une ligne précise + d'un objet choisie à la création du nœud (pas encore "la ligne sur + laquelle le joueur vient de cliquer" dans un Répéteur) ; il n'y a pas + encore de nœud "ET / OU" pour combiner plusieurs conditions avant un + branchement ; l'effet "va-et-vient" ne survit pas à un rechargement de + page (pour un état qui doit être sauvegardé, utiliser plutôt une + Condition sur un champ Oui/Non d'un objet, combinée à une action + "Modifier une donnée" en "Basculer Vrai/Faux"). + - **Mise en page de l'éditeur d'écran, revue** : la page ne défile plus + jamais elle-même — elle occupe exactement la hauteur de la fenêtre. + Les trois panneaux (ajout d'éléments, écran, propriétés) s'arrêtent + tous au même niveau en bas, et défilent chacun pour soi si leur + contenu dépasse (la liste d'éléments, l'écran lui-même en orientation + portrait sur un grand écran, etc.). La grille "Ajouter un élément" est + désormais repliable (clique son titre) pour libérer de la place. Le + panneau "🔀 Logique de la scène", une fois ouvert, partage la hauteur + de la fenêtre avec le reste au lieu de s'afficher par-dessus : une + poignée juste au-dessus de son titre permet de régler sa hauteur à la + souris (glisser vers le haut pour l'agrandir, vers le bas pour le + réduire). + - **Répéteur de données** : relie un élément à un objet défini dans ce jeu + (voir "Définir un objet" plus haut) et affiche automatiquement une ligne + par donnée existante, à partir d'un modèle de texte avec des + `{{nom_du_champ}}` — par exemple, un objet "Niveau" avec des champs + "nom" et "description" peut s'afficher comme `{{nom}} — {{description}}` + répété pour chaque niveau existant, sans rien recopier à la main. La + disposition interne (empilement/ligne/colonne + espacement) s'applique + à la liste générée. Réglage "Modèle de ligne" : au lieu du modèle de + texte, on peut choisir un élément de jeu du catalogue — chaque ligne + est alors affichée avec sa mise en forme complète (voir "Éléments de + jeu" ci-dessous) plutôt qu'en texte brut. + - **Imbrication réelle d'éléments** : un Conteneur / décor, un Répéteur de + données ou un Groupe de champs peut désormais contenir d'autres + éléments *physiquement* — texte, image, bouton, un autre conteneur... + posés dedans plutôt qu'à côté. Dans le panneau de droite, un élément de + ce type affiche une section "Contenu du conteneur" : la liste de ce + qu'il contient déjà, et la même grille d'icônes que "Ajouter un + élément" pour y placer un nouvel élément. Comme ces éléments deviennent + de vrais enfants dans la page générée, la disposition interne (ligne / + colonne), l'espacement intérieur (padding) et la bordure du parent + s'appliquent réellement à eux — exactement comme le ferait une vraie + mise en page flexbox. Un élément posé à l'intérieur d'un Répéteur de + données reçoit lui aussi les `{{nom_du_champ}}` de la ligne en cours : + il se répète donc avec le reste. L'imbrication peut se faire sur + plusieurs niveaux (un conteneur dans un conteneur, etc.). Un élément + posé à l'intérieur d'un autre garde sa hauteur naturelle (comme sur une + vraie page web) au lieu de forcer 100% de la hauteur du parent — sinon + un deuxième élément ajouté dans le même conteneur se retrouvait poussé + hors de la zone visible et disparaissait. + - **Échelle (zoom)** : un nouveau réglage curseur, disponible sur tous les + éléments (dans "Disposition"), pour agrandir ou réduire un élément sans + toucher à sa largeur/hauteur — utile par exemple pour un effet de + zoom au survol ou une icône plus petite que sa zone cliquable. + - **Disposition interne complète** (conteneurs, répéteurs, listes, + tableaux, groupes de champs) : en plus de ligne/colonne, les variantes + inversées (ordre inversé), l'alignement transversal (étirés / au + début / centrés / à la fin), la répartition dans le sens de la + disposition (au début / au centre / à la fin / espacement égal entre, + autour, ou uniforme) et une case "autoriser le retour à la ligne" — + de quoi retrouver, avec des mots simples, tout ce qu'une vraie + disposition flexbox permet de régler. + - **Taille dans le conteneur** (nouveau réglage "Taille dans le + conteneur", disponible sur tous les éléments) : deux curseurs Largeur / + Hauteur en pixels, à 0 par défaut (comportement automatique — un + élément posé à l'intérieur d'un conteneur/répéteur/groupe de champs + remplit la largeur disponible et s'ajuste en hauteur à son contenu). + Une valeur non nulle impose une taille fixe et n'est plus comprimée par + la disposition flex environnante : c'est ce qui permet de redimensionner + une image (ou n'importe quel autre élément) posée à l'intérieur d'un + conteneur — jusqu'ici impossible. + - **Correction — collision en disposition "ligne"** : dans un conteneur + réglé sur "Alignés côte à côte (ligne)", chaque élément posé à + l'intérieur réclamait par défaut 100% de la largeur du conteneur (comme + dans les autres dispositions) — deux éléments côte à côte se + retrouvaient donc à se disputer toute la largeur, et la disposition + flex les comprimait fortement pour les faire tenir (un des deux, souvent + une image, pouvait finir écrasé à quelques pixels de large, voire + disparaître visuellement). Par défaut, un élément posé dans une + disposition en ligne prend maintenant sa taille naturelle (comme le + ferait un élément Elementor/Webflow), ce qui rend aussi immédiatement + visibles "Espacement égal entre eux / autour / uniforme" : tant que + chaque élément occupait 100% de la largeur, il n'y avait aucun espace + restant à répartir entre eux. + - **Image — ajustement dans son cadre (object-fit)** : nouveau réglage + (Remplir en recadrant / Tout montrer sans déformer / Étirer) qui + contrôle comment l'image se comporte quand sa taille réelle ne + correspond pas exactement à la taille de son cadre — utile dès qu'une + largeur et une hauteur fixes sont données à une image (voir "Taille + dans le conteneur" ci-dessus) pour éviter qu'elle soit déformée. + "Remplir en recadrant" est désormais la valeur par défaut (avant, une + image étirée dans un cadre qui n'avait pas ses proportions était + déformée sans recours). + - **Nommer un élément** : chaque élément peut recevoir un nom (ex: "Bouton + Valider", "Image du héros") dans ses propriétés, pour s'y retrouver + dans les listes et dans le choix d'une cible d'action sans avoir à + deviner lequel est lequel parmi plusieurs éléments du même type. Sans + nom, l'élément garde son libellé de type par défaut (ex: "Image"). + - **Sélection directe d'un élément imbriqué** : plus besoin de repasser + par la liste "Éléments de cet écran" à chaque fois — un clic sur + l'écran sélectionne précisément l'élément touché, même s'il est + physiquement à l'intérieur d'un conteneur, d'un répéteur ou d'un + groupe de champs (le glisser-déposer, lui, continue de déplacer la + boîte de plus haut niveau, comme avant). + - **Éléments de jeu** (`🧩` depuis le tableau de bord) : un catalogue de + conteneurs réutilisables (Personnage, Outil, icône de mail...), chacun + bâti exactement comme un écran — on ouvre "✏️ Modifier le contenu" et on + y imbrique/stylise des éléments avec l'éditeur d'écran normal (c'est en + réalité un écran caché, invisible dans la liste des écrans du jeu et en + mode jouable). On peut optionnellement le lier à un objet, pour utiliser + `{{nom_du_champ}}` dans son contenu. Une fois construit, il a deux + usages : + - **Posé directement sur un écran** (bouton "🧩 Élément de jeu" dans + "+ Ajouter un élément") : tout son contenu est copié en profondeur en + éléments indépendants sur cet écran, modifiables séparément par la + suite — comme avant. Limite de cette version : si l'élément est lié à + un objet, `{{nom_du_champ}}` n'est alors résolu par aucune ligne + précise et reste affiché tel quel ; ce mode convient surtout aux + éléments décoratifs (icônes, outils, décor) sans lien à un objet. + - **Choisi comme "Modèle de ligne" d'un Répéteur de données** (réglage + du Répéteur, à la place du modèle de texte) : pour chaque ligne de + l'objet affiché par le Répéteur, son contenu est réaffiché en direct + (jamais copié en base) avec les `{{nom_du_champ}}` de cette ligne — + c'est ainsi qu'un objet "Mail" avec 5 lignes peut s'afficher comme 5 + cartes stylisées (icône + sujet + expéditeur...) plutôt que comme du + texte brut. Le Répéteur et l'éditeur d'écran n'ont besoin de rien + connaître du contenu de l'élément de jeu utilisé : la mise en forme + vit entièrement dans le catalogue, réutilisable d'un écran (ou d'un + jeu) à l'autre. + Supprimer un élément de jeu du catalogue supprime aussi son contenu + (l'écran caché) ; c'est bloqué tant qu'au moins un exemplaire est posé + directement sur un écran (voir "Suppression protégée" ci-dessous). + - **Mode jouable** (`▶️ Jouer`) : joue réellement l'enchaînement des + écrans et déclenche les actions au clic, dans le navigateur, à partir + des données enregistrées (y compris les Répéteurs, lus en direct) — + aucun fichier généré. + - Suppression protégée : un écran ciblé par une action, ou un élément de + jeu posé sur un écran, ne peuvent pas être supprimés tant que la + référence existe. + - Limite volontaire de cette version : le Tableau et la Liste déroulante + se remplissent encore via un champ de texte (une ligne par entrée), pas + en y glissant d'autres éléments un par un — l'imbrication réelle + (Conteneur / Répéteur de données / Groupe de champs) est, elle, + disponible (voir "Imbrication réelle d'éléments" ci-dessus). + - **Corrections** : le bouton "Définir un nouvel objet" du tableau de bord + a désormais le même style de carte que les autres raccourcis (Écrans, + Éléments de jeu, Jouer) ; une erreur `database is locked` pouvait + survenir en supprimant un élément (deux requêtes SQLite concurrentes + sur le serveur de développement) — corrigé en activant le mode WAL et + un délai d'attente sur les verrous de la base. + - **Confusion "Ajouter un élément" vs "Ajouter DANS ce conteneur"** : + les deux grilles se ressemblaient à l'identique (mêmes icônes, même + mise en page), alors qu'elles ne posent pas l'élément au même endroit + — celle de gauche le pose directement SUR L'ÉCRAN, celle de droite (qui + n'apparaît que si un conteneur/répéteur/groupe de champs est + sélectionné) le pose À L'INTÉRIEUR de cet élément. Cliquer par erreur + sur celle de gauche pendant qu'un conteneur est sélectionné posait un + nouvel élément par-dessus tout le reste (toujours à la même position + par défaut), qui pouvait donner l'impression trompeuse qu'un "élément + indésirable" venait d'apparaître et d'en cacher un autre. Corrigé par + trois changements : la grille de gauche s'appelle maintenant "Ajouter + un élément **sur l'écran**" (sans ambiguïté) ; celle de droite + s'appelle "📥 Ajouter **DANS ce conteneur**" avec un habillage vert + distinct ; et un avertissement apparaît dans le panneau de gauche + quand un conteneur est sélectionné, pour rappeler laquelle des deux + grilles utiliser. Un nouvel élément posé directement sur l'écran est + aussi désormais légèrement décalé par rapport au précédent (au lieu de + toujours atterrir exactement à la même position), pour limiter les + recouvrements accidentels même en cas d'usage normal. + - **Une couleur de fond apparaissait toute seule en changeant la + disposition/l'alignement d'un conteneur, et un élément pouvait + "disparaître"** : le panneau de propriétés d'un élément envoie TOUS ses + réglages dans un seul formulaire — donc enregistrer un changement de + disposition envoyait aussi, par exemple, le champ de couleur de fond, + même non modifié. Or un `` affiche toujours une + valeur (la couleur par défaut du réglage, purement indicative, quand + rien n'a encore été choisi) — cette valeur d'aperçu était donc écrite + "en dur" dans le style à chaque enregistrement, quel que soit le champ + réellement modifié, ce qui faisait apparaître un fond qui n'avait + jamais été demandé (et, en s'accumulant avec d'autres réglages par + défaut, pouvait rendre un élément visuellement méconnaissable). Corrigé + à la racine : un réglage n'est désormais écrit dans le style/les + attributs que s'il a été explicitement modifié — sa valeur par défaut + reste un simple aperçu tant qu'on n'y touche pas. Les réglages dont la + valeur par défaut a un effet visuel réellement voulu dès la création + (ex. "Ajustement dans son cadre" = cover pour une image, afin qu'elle + se recadre proprement une fois redimensionnée plutôt que d'être + étirée) sont désormais fixés directement à la pose de l'élément, pas + au premier enregistrement du panneau — cela ne dépend donc plus de + l'ordre dans lequel les réglages sont modifiés. + +### Sur l'ordre de création avec des relations + +Une relation pointe toujours vers un objet **déjà défini**. Donc si tu veux +un objet "Niveau" avec un champ relation vers "Parcours", il faut créer +l'objet **"Parcours" en premier** (même sans aucune donnée dedans), puis +créer "Niveau" et choisir "Parcours" comme objet lié pour son champ +relation. C'est l'inverse de l'exemple donné : c'est l'objet **visé** par +la relation qui doit exister avant l'objet qui la porte — pas l'objet qui +la porte avant l'objet visé. + +### Navigation sans rechargement de page (zéro rechargement) + +Toute la navigation dans l'application (pas seulement l'éditeur d'écran) +passe maintenant par `static/pjax.js`, une petite couche "PJAX" (dans +l'esprit de Turbo/Hotwire) : chaque clic sur un lien interne et chaque +soumission de formulaire interne est intercepté, la page suivante est +récupérée en arrière-plan (`fetch`), et seuls le ``, l'en-tête (fil +d'Ariane) et le contenu principal (`<main>`) sont remplacés — au lieu de +laisser le navigateur recharger toute la page. L'URL affichée et le bouton +"précédent" du navigateur restent corrects grâce à +`history.pushState`/`popstate`. + +Pourquoi PJAX plutôt qu'une réécriture complète en SPA (React ou autre) : +le moteur reste 100% rendu côté serveur en Jinja2, ce qui est le bon choix +pour un outil interne piloté par une base SQLite par jeu — une SPA aurait +demandé de dupliquer toute la logique d'affichage côté client (une API +JSON, un routeur, un state management) pour un gain quasi nul ici, alors +que PJAX obtient le même résultat perçu ("zéro rechargement", historique +correct, formulaires qui marchent) en ne touchant qu'à une seule couche +fine, sans toucher aux routes Flask ni aux templates existants. + +Points d'attention si tu ajoutes une page avec un script propre à cette +page (dans `{% block content %}` ou via `extra_head`) : + +- Ce script est réinjecté et **réexécuté** à chaque navigation vers cette + page (y compris quand on y revient plusieurs fois) : évite `let`/`const` + au premier niveau du script (une redéclaration lèverait une erreur) — une + déclaration de fonction ou une variable `var` est sans risque. +- Pour désactiver PJAX sur un lien ou un formulaire précis (rare), ajoute + l'attribut `data-no-pjax`. Les liens `target="_blank"` (ex. "▶️ Jouer") + et les liens de téléchargement (`download`) sont déjà ignorés + automatiquement. +- Les formulaires protégés par un `confirm()` JavaScript (ex. suppression) + continuent de fonctionner normalement : si l'utilisateur annule la + boîte de dialogue, PJAX n'intercepte rien et rien ne se passe. + +### Éditeur d'écran : tout est automatique (déplacer, redimensionner, régler) + +L'éditeur d'écran (`templates/screen_edit.html`) va plus loin que le PJAX +général ci-dessus, parce qu'il s'agit d'un usage bien plus intensif : +déplacer/redimensionner un élément à la souris et régler ses propriétés +sont des actions qu'on répète en continu pendant qu'on construit un écran, +pas des navigations occasionnelles. Deux comportements dédiés, indépendants +de `pjax.js` : + +- **Glisser-déposer et redimensionnement** ne provoquent plus aucune + navigation. Tant que l'élément déplacé/redimensionné reste celui déjà + sélectionné, rien n'est rechargé : sa nouvelle position/taille est + envoyée en arrière-plan (`fetch`) et les champs "X/Y/Largeur/Hauteur" du + panneau de droite sont mis à jour directement en JavaScript. Cliquer + (sans glisser) sur un AUTRE élément pour le sélectionner ne recharge pas + la page non plus : seul le bloc central de l'éditeur (`#builder3` — la + liste d'éléments, le canevas et le panneau de propriétés) est regénéré + via une requête en arrière-plan et réinjecté, sans toucher au reste de la + page (l'éditeur de logique en bas, par exemple, garde son état). +- **Le panneau de propriétés s'enregistre entièrement tout seul** — il n'y + a plus de bouton "Enregistrer". Chaque réglage se sauvegarde dès qu'on le + change : immédiatement pour une case à cocher, une couleur ou une liste + déroulante ; après une courte pause (500 ms) pour un champ texte ou un + slider qu'on est en train de glisser, pour éviter d'envoyer une requête à + chaque caractère tapé. Un indicateur ("Enregistrement..." / "Enregistré + automatiquement ✓") remplace l'ancien bouton. Après chaque sauvegarde, + seul le canevas (`#canvas`) est rafraîchi pour refléter le changement + visuellement — le formulaire de propriétés lui-même n'est jamais + retouché, pour ne jamais faire perdre le focus ou la position du curseur + pendant qu'on tape. + +Point d'attention si tu ajoutes un nouveau contrôle ou une nouvelle zone +dans ce panneau : toute logique branchée avec `addEventListener` (pas un +attribut `onclick`/`onchange` inline) doit être (re)branchée dans +`initBuilderPanel()` — cette fonction est rappelée après chaque changement +de sélection, puisque `#builder3` est entièrement regénéré à ce moment-là. + +### Cliquer une ligne de Répéteur (déclencheur + action "Ouvrir la ligne cliquée") + +Un Répéteur affiche une ligne par entrée d'un objet de données (ex : la +liste des mails d'une boîte de réception), mais toutes ses lignes +partagent le même modèle d'éléments — il n'existait donc aucun moyen de +dire "au clic sur CETTE ligne précise, ouvre CE mail précis" : un +déclencheur ne pouvait viser qu'un élément fixe posé une fois pour toutes +sur l'écran. + +Ça fonctionne maintenant ainsi : + +- Chaque ligne rendue par un Répéteur porte un attribut `data-row-id` (le + vrai id de la ligne de données, voir `screens/rendering/render_repeater.py`) + — à ne pas confondre avec `data-element-id`, qui reste l'id du MODÈLE de + ligne et se répète à l'identique sur chaque ligne. +- Dans l'éditeur de logique, choisir le Répéteur lui-même comme "Élément" + d'un nœud Déclencheur ("Au clic") fait réagir n'importe laquelle de ses + lignes au clic (le clic remonte naturellement jusqu'au conteneur du + Répéteur). +- La nouvelle action **"Ouvrir la ligne de Répéteur cliquée"** retient + quelle ligne a réellement été cliquée (`window.lastClickedRowId` / + `window.lastClickedDefinitionId`, capturés au clic dans `bindClicks()` + de `templates/play.html`), affiche l'écran de détail choisi, puis + résout tous les `{{champ}}` restés tels quels sur cet écran + (`applyOpenRowBindings()`) avec les valeurs de CETTE ligne — alors que + jusqu'ici `{{champ}}` ne se résolvait qu'à l'intérieur d'un Répéteur + (limitation encore documentée dans les tests pour un élément posé + directement depuis le catalogue). +- Limite connue : si le déclencheur est posé sur un élément à l'INTÉRIEUR + du modèle de ligne (ex: juste le titre) plutôt que sur le Répéteur + lui-même, `data-definition-id` n'est pas disponible sur cet élément et + l'action ne saura pas résoudre la donnée — pose toujours le déclencheur + sur le Répéteur. + +### Un seul écran qui s'adapte à la partie (filtre de Répéteur + déclencheur "affichage") + +Certains jeux (ex: une boîte mail avec plusieurs niveaux) n'ont besoin que +d'un SEUL écran de jeu, dont le contenu doit changer selon l'état de la +partie (niveau atteint, outils débloqués...) plutôt que d'un écran par +niveau à maintenir en synchronisation. Deux briques rendent ça possible : + +- **Filtre sur un Répéteur** (nouveaux réglages "Filtre" dans ses + propriétés) : ne garde que les lignes dont un champ correspond à une + valeur — fixe (ex. `3`) OU une référence `{{NomDeLObjet.nom_du_champ}}` + qui va lire la valeur ACTUELLE de ce champ sur la ligne la plus récente + de cet autre objet (voir `screens/rendering/filter_repeater_rows.py`). + Convention : un objet utilisé comme "état de partie" (ex. un objet + "Partie" avec un champ "niveau_courant") ne garde qu'UNE seule ligne, + mise à jour en place par des actions "Modifier une donnée" plutôt que + d'en créer une nouvelle à chaque fois — la référence prend toujours la + ligne la plus récente. Le filtre est réévalué à chaque régénération du + HTML du jeu (chargement de `/play`, et rafraîchissement après toute + action "Modifier une donnée"), donc automatiquement à jour. +- **Déclencheur "À l'affichage de l'écran"** (nouvel événement, en plus de + "Au clic" et "À la soumission") : contrairement aux autres déclencheurs, + il ne cible pas un élément précis mais l'ÉCRAN ENTIER, et s'exécute + automatiquement — au premier affichage, à chaque retour sur cet écran, ET + après toute donnée modifiée pendant qu'on y est déjà (voir + `runScreenShowTriggers()` dans `templates/play.html`, appelée depuis + `showScreen()` et `refreshRuntimeData()`). Combiné à un nœud Condition et + une action "Modifier un élément → Visibilité", ça permet de cacher/montrer + un élément selon l'état de la partie SANS qu'un clic soit nécessaire pour + le réévaluer — la limite qui empêchait un outil de réapparaître "débloqué" + simplement en revenant sur l'écran. À éviter avec le mode "va-et-vient" + d'une action liée : il serait réévalué à chaque affichage et basculerait + de façon imprévisible plutôt que de rester stable. + +Point d'attention : le filtre de Répéteur ne fait pas de vraie jointure — +la référence `{{Objet.champ}}` prend la ligne la plus récente de l'objet +visé, pas "la ligne liée à la ligne courante d'un autre Répéteur" ; pour un +état de partie à une seule ligne (le cas d'usage visé), ça suffit. + +## Jauge liée à une donnée, bornage automatique, onglets, conditions combinées + +Quatre briques qui se combinent bien avec le déclencheur "À l'affichage de +l'écran" ci-dessus, pour construire un écran qui réagit à l'état de la +partie sans code : + +- **Widget "Jauge (liée à une donnée)"** (nouveau widget, icône 📊) : + affiche une barre dont la largeur ET la couleur suivent en direct un + champ numérique d'un objet — pas besoin d'ajouter la moindre action, la + jauge se relit automatiquement à chaque rafraîchissement des données + (chargement de `/play`, après toute action "Modifier une donnée"). Ses + réglages : Objet, Champ, Min/Max (bornes d'affichage — la barre est visuellement + clampée à 0%/100% même si la vraie valeur dépasse), Couleur basse/haute + (dégradé interpolé linéairement selon la position dans la plage), et une + case "Afficher la valeur" pour incruster le nombre au centre de la barre. + Lit toujours la ligne la PLUS RÉCENTE de l'objet visé (même convention + "état de partie à une seule ligne" que le filtre de Répéteur). Voir + `screens/rendering/render_jauge.py`. +- **Bornage automatique des champs numériques** : un champ "Nombre entier" + ou "Nombre décimal" peut désormais avoir un Min et/ou un Max réglés à la + création de l'objet (ou ajoutés après coup en édition). Toute action + "Modifier une donnée" (Augmenter de, Diminuer de, Définir à...) qui + ferait sortir la valeur de cette plage est automatiquement RAMENÉE à la + borne dépassée, plutôt que de continuer à s'accumuler sans limite — plus + besoin de poser une Condition séparée juste pour empêcher une jauge de + réputation de dépasser 100 ou de passer sous 0. Voir + `_clamp_to_field_bounds()` dans `screens/data_actions/apply_data_action.py`. +- **Panneau à onglets / visibilité mutuellement exclusive** : nouveau type + d'action "Afficher cet élément, masquer tous ses frères (onglets / + exclusif)" (`activer_onglet`). Au lieu de poser une action "Modifier + élément → Visibilité" par bouton à masquer (N-1 actions pour N onglets), + UNE SEULE action suffit : au clic, elle montre l'élément ciblé et cache + automatiquement tous les autres éléments qui partagent le même parent + (même conteneur) dans l'écran — exactement le comportement d'un jeu + d'onglets ou d'un menu à options exclusives. Voir la fonction + `runActionNode()` (branche `'activer_onglet'`) dans `templates/play.html`. +- **Conditions combinées (ET / OU)** : un nœud Condition peut désormais + tester PLUSIEURS champs à la fois dans un seul nœud, au lieu d'enchaîner + plusieurs nœuds Condition pour un ET, ou de dupliquer une action derrière + plusieurs nœuds Condition pour un OU. Dans le formulaire d'un nœud + Condition, le bouton "+ Ajouter une condition" ajoute une clause + supplémentaire (Objet / Ligne / Champ / Condition / Valeur, comme la + clause principale) et fait apparaître un sélecteur "Combiner les + conditions avec : ET / OU". Exemple : "réputation < 20 OU niveau ≥ 3". + Compatibilité : un nœud créé AVANT cette fonctionnalité (ou un nœud à une + seule clause) n'a pas de `cond_clauses` en base et garde exactement son + ancien comportement (une seule comparaison) — voir `cond_clauses` / + `cond_combinator` dans `screens/flow/ensure_flow_schema.py` et + `evaluateConditionNode()` / `evaluateConditionClause()` dans + `templates/play.html`. + +## Survol, séquences temporisées, surbrillance, overlay, verrouillage + +Cinq briques de confort, chacune contournable en théorie mais coûteuse en +câblage manuel sans elles : + +- **Interactions au survol** : un nouveau réglage "Texte affiché au survol + de la souris", disponible sur TOUS les widgets (via UNIVERSAL_CONTROLS — + voir `screens/widgets/control_groups/hover_controls.py`), échange le + texte affiché contre ce texte alternatif tant que la souris survole + l'élément, puis le restaure au départ de la souris — exactement le cas + d'usage visé ("survoler un nom révèle l'adresse réelle, survoler un lien + révèle l'URL réelle"). Laissé vide = aucun changement, donc aucune + régression sur les éléments déjà créés. Ignoré volontairement sur les + éléments qui ont des enfants (conteneurs), pour ne jamais écraser une + mise en page imbriquée. Voir l'attribut `data-hover-text` posé par + `render_element_html.py` et `bindHoverTexts()` dans `templates/play.html`. +- **Séquences temporisées** : nouveau type d'action "Attendre quelques + secondes avant de continuer" (`attendre`), qui suspend la SUITE du fil de + logique (les nœuds reliés après lui) pendant N secondes avant de + continuer — combiné au déclencheur "À l'affichage de l'écran" (voir plus + haut), ça permet un mail ou un événement qui "arrive" tout seul quelques + secondes après l'ouverture de l'écran, sans action du joueur. Le joueur + continue d'interagir normalement avec le reste de l'écran pendant + l'attente. Voir la branche `'attendre'` de `runActionNode()` dans + `templates/play.html`. +- **Surbrillance générique dynamique** : nouvelle valeur "Surbrillance" + pour l'action "Modifier un élément", applicable à N'IMPORTE QUEL élément + (pas seulement les boutons) — pose un liseré doré clignotant (`.forgeHighlight`, + animation CSS) pour attirer l'attention du joueur vers ce qu'il doit + toucher ensuite (ex. un mentor qui "montre" un bouton), sans avoir à + poser une bordure de couleur togglée à la main. Activer / Désactiver / + Basculer, comme la Visibilité. +- **Overlay / modale réutilisable** : nouveau widget "Superposition / boîte + de dialogue" (icône 🪟), prêt à l'emploi — contrairement à tous les autres + widgets, sa position ne dépend PAS de l'endroit où il est glissé sur le + canevas (`position:fixed; inset:0`, codé en dur) : il couvre TOUJOURS tout + l'écran, avec un voile semi-transparent et une boîte centrée qui contient + les éléments posés à l'intérieur (comme un conteneur normal). Démarre + MASQUÉ par défaut à sa création (sinon il couvrirait l'écran dès qu'on le + pose) — l'ouverture et la fermeture réutilisent l'action existante + "Modifier un élément → Visibilité" (rendre visible / masquer), fermeture + manuelle uniquement, sans mécanisme de clic-en-dehors-pour-fermer. Voir + `screens/rendering/render_overlay.py`. +- **Verrouillage d'un élément après décision** : nouvelle valeur + "Désactivé" pour l'action "Modifier un élément", DISTINCTE de + "Invisible" — l'élément reste visible mais devient grisé et inerte + (`.forgeDisabled` : opacité réduite + `pointer-events:none`, qui bloque + aussi le déclencheur "Au clic" éventuellement posé dessus). Utile pour un + bouton "Répondre" qui reste affiché mais ne doit plus pouvoir être + recliqué une fois le mail traité. Activer / Désactiver / Basculer. + +## Structure + +Le code est organisé en un fichier par fonction et un dossier par +responsabilité — pensé pour rester modifiable, testable et évolutif à +mesure que le moteur grandit, plutôt que quelques gros fichiers monolithiques. +`app.py` reste le point d'entrée (`python3 app.py` ne change pas), mais il +n'est plus qu'un mince assemblage : toute la logique vit dans les dossiers +ci-dessous. + +``` +app.py point d'entrée : assemble core/ + routes/, lance le serveur + +core/ pièces transverses de l'application Flask + flask_app.py crée l'instance Flask partagée (app) + jinja_filters.py enregistre les filtres Jinja (colname, elstyle, elabel) + +filters/ un fichier par filtre Jinja + colname_filter.py + element_style_filter.py + +routes/ un fichier par route HTTP, regroupées par domaine + games/ créer/renommer/supprimer un jeu, tableau de bord + objects/ définitions d'objet, champs, données (CRUD) + screens/ écrans du jeu (liste, création, édition...) + elements/ éléments posés sur un écran + element_types/ catalogue d'éléments de jeu réutilisables + flow/ éditeur de logique à nœuds (déclencheur/condition/action) + legacy_actions/ ancien système d'actions par élément (conservé, non utilisé par l'interface actuelle) + uploads/ envoi de fichiers (images/vidéos) + play/ mode jouable + +db/ couche données "objets" — un fichier par fonction + connection.py, slugify.py, table_name_for.py, game_dir.py, db_path.py, constants.py + games/ cycle de vie d'un jeu + definitions/ définitions d'objet et leurs champs + rows/ lignes de données (CRUD) + +screens/ couche données + rendu "écrans de jeu" — un fichier par fonction + widgets/ catalogue de widgets, réglages ("controls"), groupes de réglages + rendering/ construction du HTML réel d'un élément (récursif) + screens_repo/ CRUD des écrans + elements/ CRUD des éléments posés sur un écran + element_types/ catalogue d'éléments de jeu réutilisables (conteneurs imbriqués) + flow/ moteur de logique à nœuds + data_actions/ exécution d'une action "modifier une donnée" + legacy_actions/ ancien système d'actions (conservé) + labels/ constantes d'affichage (libellés) + payload/ assemble les données du mode jouable + +templates/ pages HTML (Jinja2) — inchangé +static/ CSS + JS — inchangé +projects/ un sous-dossier par jeu créé (généré à l'usage) — inchangé + +tests/ suite de tests automatisés (pytest) + conftest.py fixtures partagées (client Flask de test, jeu de test jetable) + test_db_layer.py couche données, sans Flask + test_screens_and_elements.py écrans/éléments/éléments de jeu/répéteur, via le client de test + test_flow.py éditeur de logique à nœuds +``` + +Chaque `import db` / `import screens` continue de fonctionner exactement +comme avant (`db.connect(...)`, `screens.render_element_html(...)`, etc.) : +`db/__init__.py` et `screens/__init__.py` réexportent l'intégralité de +l'API publique historique, pour que ce découpage interne reste invisible du +reste du moteur — aucune route, aucun template n'a eu besoin de changer. + +### Lancer les tests + +```bash +pip install -r requirements-dev.txt +pytest +``` + +## Corrections (retours d'usage) + +- **ID d'élément en champ non modifiable** : dans le panneau de propriétés, + l'ID de l'élément (`elt-…`) est maintenant affiché dans un champ texte + `readonly`, plutôt qu'en simple texte. +- **Bouton "+10 points" instable en mode Jouer (corrigé)** : un bouton relié + à une action "Modifier une donnée" (ex: incrémenter un score) pouvait, après + plusieurs clics, appliquer l'effet plusieurs fois d'un coup (+100, -20, ou + retomber à 0 au lieu de ±10). Cause : chaque donnée modifiée déclenche un + rafraîchissement qui ré-attache les gestionnaires de clic sur toute la + scène ; un bouton ordinaire (contrairement à un Répéteur ou une Jauge) garde + le même nœud HTML d'un rafraîchissement à l'autre, donc ses gestionnaires de + clic s'empilaient au lieu d'être remplacés. Un clic fini par déclencher + l'action N fois pour N rafraîchissements passés. Corrigé en marquant chaque + élément déjà relié, pour ne l'attacher qu'une seule fois. +- **Textes d'aide allégés** : les explications détaillées entre parenthèses + affichées un peu partout dans l'éditeur ont été retirées ou raccourcies — + sur tous les écrans, pas seulement l'éditeur de scène (nouvelle passe + complète sur tous les labels, options de menus déroulants et placeholders). +- **Espacement (gap) inefficace en disposition par défaut, corrigé** : le + réglage "Espace entre les éléments" n'avait aucun effet tant que la + "Disposition interne" restait sur "Empilement libre" — le CSS `gap` ne + fonctionne que sur un conteneur flex/grid, or "Empilement libre" ne posait + aucun `display:flex`. Un conteneur/répéteur/groupe de champs applique + maintenant flex + colonne par défaut dès qu'aucune disposition n'a été + choisie explicitement (visuellement identique à l'empilement d'avant), + donc l'espacement fonctionne dès la création, sans avoir à toucher la + disposition au préalable. +- **Marges et bordures réglables par côté** : en plus des réglages globaux + existants ("Marge intérieure", "Marge extérieure", "Épaisseur de la + bordure"), chaque côté (haut/droite/bas/gauche) peut désormais être réglé + individuellement — utile par exemple pour une seule bordure basse, ou un + padding asymétrique. Les réglages par côté, une fois utilisés, priment sur + le réglage global correspondant. +- **Apparence de l'éditeur après suppression d'un élément** : supprimer un + élément qui a un parent (posé à l'intérieur d'un conteneur/répéteur/groupe + de champs) resélectionne maintenant ce parent, au lieu de laisser le + panneau de propriétés vide. + +- **Timeline d'animation (Animate.css + animations personnalisées)** : dans + l'éditeur d'écran, un nouveau panneau rétractable "🎬 Timeline d'animation" + est positionné sous "🔀 Logique de la scène". Il permet de poser des clips + d'animation sur n'importe quel élément de l'écran, sur un axe temporel en + secondes (depuis l'affichage de l'écran) : + - **Animations Animate.css** : choix parmi le catalogue complet de la + bibliothèque (apparitions, rebonds, glissements, zooms, rotations, + retournements, etc.), avec durée, délai, easing et nombre de répétitions + réglables. + - **Animations personnalisées** : un éditeur d'images-clés (keyframes) — + chaque image-clé a un pourcentage (0-100) et une liste de propriétés CSS + (ex. `transform: scale(1.2); opacity: 0.5;`), converties en `@keyframes` + CSS au moment de la lecture. + - Chaque clip se pose et se règle à la souris directement sur la + timeline : glisser un clip change son instant de départ, glisser son + bord droit change sa durée ; cliquer dessus (sans glisser) ouvre son + formulaire d'édition détaillé, avec un bouton "▶ Aperçu" pour voir + l'animation directement sur le canevas d'édition. + - En mode Jouer, chaque clip se déclenche automatiquement à son instant de + départ lors de l'affichage de l'écran qui le contient. +- **Échelle/animation d'un élément posé sur l'écran, corrigée** : l'échelle + ("Disposition" → curseur "Échelle") et les animations de la timeline + restaient visuellement "coincées" à l'intérieur de la boîte d'origine de + l'élément, comme si l'effet s'appliquait à un contenu emprisonné dans un + cadre immobile — c'était bien le cas : ces réglages vivaient sur la balise + intérieure, alors que le cadre qui porte réellement la position/taille de + l'élément sur l'écran (et son contour de sélection dans l'éditeur) restait + fixe autour. L'échelle et les animations visent maintenant ce cadre + directement, comme le reste de l'élément — plus de recadrage parasite. Un + élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de champs n'a + jamais eu ce souci (pas de cadre séparé pour lui). +- **Format d'aperçu (Portrait/Paysage/Carré) mémorisé par écran** : ce choix + ne vivait qu'en mémoire dans le navigateur — poser ou supprimer un + élément, ou sélectionner un autre élément, redemande ce panneau au + serveur, qui repartait alors systématiquement sur Portrait. Résultat : + l'écran de travail semblait "s'agrandir ou rétrécir" au fil des actions + quand on travaillait en Paysage ou en Carré (retour brutal en Portrait à + chaque fois). Le format choisi est maintenant enregistré par écran et + restauré à chaque fois que ce panneau est réaffiché. +- **Zone de jeu à proportion fixe en mode Jouer, contre la déformation en + %** : les positions et tailles des éléments sont en pourcentage de + l'écran, ce qui les déformait dès que la fenêtre du joueur n'avait pas + exactement la même proportion que celle choisie à la conception (ex. un + écran conçu en Portrait, joué dans une fenêtre large, étirait/écrasait + tout). La zone de jeu garde maintenant toujours la proportion de l'écran + affiché (Portrait/Paysage/Carré, par écran), avec des bandes noires + (letterboxing) si besoin — comme un lecteur vidéo — pour rester fidèle à + ce qui a été conçu, quel que soit l'écran du joueur. + +## Prochaines fonctionnalités + +À ajouter au fur et à mesure, comme convenu. diff --git a/app.py b/app.py new file mode 100644 index 00000000..668443b8 --- /dev/null +++ b/app.py @@ -0,0 +1,32 @@ +""" +app.py — moteur Forge, point d'entrée. + +Lance un serveur web local. Fonctionnalités construites pour l'instant +(le reste viendra au fur et à mesure) : + 1. Créer un jeu (dossier + index.html/css/js + base de données dédiée). + 2. Définir des "objets" (comme des tables de base de données, avec des + champs typés, et des relations vers d'autres objets déjà définis). + 3. Le moteur lit ces définitions et génère lui-même le formulaire adapté + pour saisir des données et les enregistrer dans la vraie base SQLite. + 4. CRUD complet sur les trois niveaux (jeu / objet / donnée) : renommer + et supprimer un jeu, modifier une définition d'objet (ajouter/retirer + des champs) et la supprimer, modifier et supprimer une ligne de + données — avec blocage/avertissement quand une suppression casserait + une relation existante. +""" + +import threading +import webbrowser + +from core.flask_app import app +from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja +import routes # noqa: F401 - enregistre toutes les routes sur `app` + + +def _open_browser(): + webbrowser.open("http://127.0.0.1:5050/") + + +if __name__ == "__main__": + threading.Timer(1.0, _open_browser).start() + app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/flask_app.py b/core/flask_app.py new file mode 100644 index 00000000..58cbccf6 --- /dev/null +++ b/core/flask_app.py @@ -0,0 +1,9 @@ +import os + +from flask import Flask + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates") +_STATIC_FOLDER = os.path.join(_BASE_DIR, "static") + +app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) diff --git a/core/jinja_filters.py b/core/jinja_filters.py new file mode 100644 index 00000000..312293fe --- /dev/null +++ b/core/jinja_filters.py @@ -0,0 +1,10 @@ +import screens + +from core.flask_app import app +from filters.colname_filter import _colname +from filters.element_style_filter import _element_style, _element_transform_style + +app.jinja_env.filters["colname"] = _colname +app.jinja_env.filters["elstyle"] = _element_style +app.jinja_env.filters["eltransform"] = _element_transform_style +app.jinja_env.filters["elabel"] = screens.element_label diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 00000000..80714494 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1,64 @@ +""" +db — toute la logique base de données du moteur Forge, organisée en un +sous-module par responsabilité (un fichier = une fonction, un dossier = un +domaine) plutôt qu'un seul gros fichier. Ce fichier réexporte l'intégralité +de l'API publique historique : le reste du moteur continue simplement +d'écrire `import db` puis `db.ma_fonction(...)`, sans rien savoir de cette +organisation interne. + +Chaque JEU créé a SA PROPRE base SQLite (fichier .db dans son dossier de +projet). Cette base contient : + - des tables "méta" (_definitions, _fields) qui décrivent les objets que + l'utilisateur a définis via l'interface (l'équivalent d'un schéma de + base de données, mais construit à la souris plutôt qu'en SQL) ; + - une VRAIE table SQL par objet défini (obj_<nom>), avec des colonnes du + bon type SQLite selon le type choisi pour chaque champ, y compris des + colonnes de clé étrangère pour les champs de type "relation". + +Rien n'est une simulation : créer une "définition d'objet" avec des champs +exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un +vrai INSERT dans cette table. +""" + +from .constants import PROJECTS_DIR, FIELD_TYPES +from .slugify import slugify +from .table_name_for import table_name_for +from .game_dir import game_dir +from .db_path import db_path +from .connection import connect + +from .games.list_games import list_games +from .games.game_meta import game_meta +from .games.create_game import create_game +from .games.update_game_name import update_game_name +from .games.delete_game import delete_game + +from .definitions.list_definitions import list_definitions +from .definitions.get_definition import get_definition +from .definitions.create_definition import create_definition +from .definitions.rename_definition import rename_definition +from .definitions.definitions_referencing import definitions_referencing +from .definitions.add_field_to_definition import add_field_to_definition +from .definitions.update_field import update_field +from .definitions.delete_field import delete_field +from .definitions.delete_definition import delete_definition + +from .rows.list_rows import list_rows +from .rows.relation_options import relation_options +from .rows.insert_row import insert_row +from .rows.get_row import get_row +from .rows.update_row import update_row +from .rows.update_row_field import update_row_field +from .rows.delete_row import delete_row +from .rows.rows_referencing import rows_referencing + +__all__ = [ + "PROJECTS_DIR", "FIELD_TYPES", + "slugify", "table_name_for", "game_dir", "db_path", "connect", + "list_games", "game_meta", "create_game", "update_game_name", "delete_game", + "list_definitions", "get_definition", "create_definition", "rename_definition", + "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", + "delete_definition", + "list_rows", "relation_options", "insert_row", "get_row", "update_row", + "update_row_field", "delete_row", "rows_referencing", +] diff --git a/db/connection.py b/db/connection.py new file mode 100644 index 00000000..5eff1c3a --- /dev/null +++ b/db/connection.py @@ -0,0 +1,20 @@ +import sqlite3 + +from .db_path import db_path + + +def connect(slug): + # timeout=10 : si une autre connexion tient un verrou d'écriture au même + # instant (deux requêtes qui arrivent presque en même temps, ex. deux + # onglets, ou le navigateur qui recharge plusieurs ressources), sqlite3 + # réessaie pendant 10 secondes au lieu de lever immédiatement "database + # is locked". Le mode WAL (Write-Ahead Logging) va plus loin : il permet + # à des lectures de se faire PENDANT qu'une écriture est en cours + # ailleurs, ce qui est la cause la plus fréquente de ce blocage avec le + # serveur de développement Flask. + conn = sqlite3.connect(db_path(slug), timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA busy_timeout = 8000") + return conn diff --git a/db/constants.py b/db/constants.py new file mode 100644 index 00000000..43b5a49b --- /dev/null +++ b/db/constants.py @@ -0,0 +1,16 @@ +"""Constantes partagées de la couche données.""" + +import os + +PROJECTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects") + +# Types de champ exposés dans l'interface -> type de colonne SQLite réel. +FIELD_TYPES = { + "texte": {"label": "Texte court", "sql": "TEXT"}, + "texte_long": {"label": "Texte long", "sql": "TEXT"}, + "nombre_entier": {"label": "Nombre entier", "sql": "INTEGER"}, + "nombre_decimal": {"label": "Nombre décimal", "sql": "REAL"}, + "booleen": {"label": "Oui / Non", "sql": "INTEGER"}, + "date": {"label": "Date", "sql": "TEXT"}, + "relation": {"label": "Relation vers un autre objet", "sql": "INTEGER"}, +} diff --git a/db/db_path.py b/db/db_path.py new file mode 100644 index 00000000..31491d58 --- /dev/null +++ b/db/db_path.py @@ -0,0 +1,7 @@ +import os + +from .game_dir import game_dir + + +def db_path(slug): + return os.path.join(game_dir(slug), "game.db") diff --git a/db/definitions/__init__.py b/db/definitions/__init__.py new file mode 100644 index 00000000..5dfd68a0 --- /dev/null +++ b/db/definitions/__init__.py @@ -0,0 +1 @@ +"""Définitions d'objet (le "schéma" créé à la souris) et leurs champs.""" diff --git a/db/definitions/add_field_to_definition.py b/db/definitions/add_field_to_definition.py new file mode 100644 index 00000000..eee858b6 --- /dev/null +++ b/db/definitions/add_field_to_definition.py @@ -0,0 +1,43 @@ +from ..connection import connect +from ..constants import FIELD_TYPES +from ..slugify import slugify +from .get_definition import get_definition + + +def add_field_to_definition(slug, definition_id, field): + """CRUD — Update d'une définition : ajoute un champ à un objet déjà + créé. Exécute un vrai ALTER TABLE ... ADD COLUMN sur la table SQL + existante (les lignes déjà enregistrées reçoivent NULL pour ce + nouveau champ, comportement standard d'un ALTER TABLE).""" + definition = get_definition(slug, definition_id) + conn = connect(slug) + fname = slugify(field["name"]).replace("-", "_") + ftype = field["type"] + required = 1 if field.get("required") else 0 + relation_definition_id = None + position = (max((f["position"] for f in definition["fields"]), default=-1)) + 1 + + if ftype == "relation": + related = get_definition(slug, int(field["relation_definition_id"])) + col = f"{fname}_id" + # ALTER TABLE ADD COLUMN de SQLite n'accepte pas de contrainte + # REFERENCES portant sur une colonne ajoutée après coup avec la + # même simplicité qu'à la création : on ajoute la colonne simple — + # c'est la table _fields qui reste la source de vérité utilisée par + # le moteur pour savoir que cette colonne est une relation. + conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {col} INTEGER") + relation_definition_id = related["id"] + else: + sql_type = FIELD_TYPES[ftype]["sql"] + conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {fname} {sql_type}") + + min_value = field.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None + max_value = field.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None + conn.execute( + """INSERT INTO _fields + (definition_id, name, type, relation_definition_id, required, position, min_value, max_value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (definition_id, field["name"], ftype, relation_definition_id, required, position, min_value, max_value), + ) + conn.commit() + conn.close() diff --git a/db/definitions/create_definition.py b/db/definitions/create_definition.py new file mode 100644 index 00000000..56fbbda0 --- /dev/null +++ b/db/definitions/create_definition.py @@ -0,0 +1,57 @@ +from ..connection import connect +from ..constants import FIELD_TYPES +from ..slugify import slugify +from ..table_name_for import table_name_for +from .get_definition import get_definition +from .ensure_field_bounds_schema import ensure_field_bounds_schema + + +def create_definition(slug, name, fields): + """Feature 2 : crée une définition d'objet (comme une table de BDD) et + exécute le vrai CREATE TABLE correspondant, avec les bons types de + colonne, y compris les colonnes de clé étrangère pour les relations + vers d'autres définitions déjà créées dans CE jeu (une définition peut + donc "en contenir" une autre, au sens relationnel).""" + ensure_field_bounds_schema(slug) + conn = connect(slug) + tname = table_name_for(name) + + conn.execute( + "INSERT INTO _definitions (name, table_name) VALUES (?, ?)", (name, tname) + ) + definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + + columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"] + for pos, f in enumerate(fields): + fname = slugify(f["name"]).replace("-", "_") + ftype = f["type"] + required = 1 if f.get("required") else 0 + relation_definition_id = None + + if ftype == "relation": + related = get_definition(slug, int(f["relation_definition_id"])) + col = f"{fname}_id" + columns_sql.append( + f"{col} INTEGER REFERENCES {related['table_name']}(id)" + ) + relation_definition_id = related["id"] + else: + sql_type = FIELD_TYPES[ftype]["sql"] + not_null = " NOT NULL" if required else "" + columns_sql.append(f"{fname} {sql_type}{not_null}") + + min_value = f.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None + max_value = f.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None + conn.execute( + """INSERT INTO _fields + (definition_id, name, type, relation_definition_id, required, position, min_value, max_value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (definition_id, f["name"], ftype, relation_definition_id, required, pos, min_value, max_value), + ) + + columns_sql.append("created_at TEXT DEFAULT CURRENT_TIMESTAMP") + create_sql = f"CREATE TABLE {tname} ({', '.join(columns_sql)})" + conn.execute(create_sql) + conn.commit() + conn.close() + return definition_id diff --git a/db/definitions/definitions_referencing.py b/db/definitions/definitions_referencing.py new file mode 100644 index 00000000..51f7cacf --- /dev/null +++ b/db/definitions/definitions_referencing.py @@ -0,0 +1,16 @@ +from ..connection import connect + + +def definitions_referencing(slug, definition_id): + """Autres définitions de CE jeu qui ont un champ de type relation + pointant vers cette définition — utilisé pour bloquer une suppression + qui casserait ces relations.""" + conn = connect(slug) + rows = conn.execute( + """SELECT DISTINCT d.id, d.name FROM _fields f + JOIN _definitions d ON d.id = f.definition_id + WHERE f.relation_definition_id = ?""", + (definition_id,), + ).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/db/definitions/delete_definition.py b/db/definitions/delete_definition.py new file mode 100644 index 00000000..719e18ed --- /dev/null +++ b/db/definitions/delete_definition.py @@ -0,0 +1,12 @@ +from ..connection import connect +from .get_definition import get_definition + + +def delete_definition(slug, definition_id): + definition = get_definition(slug, definition_id) + conn = connect(slug) + conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}") + conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,)) + conn.execute("DELETE FROM _definitions WHERE id = ?", (definition_id,)) + conn.commit() + conn.close() diff --git a/db/definitions/delete_field.py b/db/definitions/delete_field.py new file mode 100644 index 00000000..d3bc2adf --- /dev/null +++ b/db/definitions/delete_field.py @@ -0,0 +1,28 @@ +import sqlite3 + +from ..connection import connect +from ..slugify import slugify +from .get_definition import get_definition + + +def delete_field(slug, definition_id, field_id): + """CRUD — Update d'une définition : retire un champ. Exécute un vrai + ALTER TABLE ... DROP COLUMN (SQLite ≥ 3.35). Sur une version de SQLite + trop ancienne pour DROP COLUMN, le champ est retiré de la définition + (le moteur ne le proposera plus dans les formulaires) mais la colonne + SQL peut subsister sans casser quoi que ce soit d'autre.""" + definition = get_definition(slug, definition_id) + field = next((f for f in definition["fields"] if f["id"] == field_id), None) + if not field: + return + col = slugify(field["name"]).replace("-", "_") + if field["type"] == "relation": + col += "_id" + conn = connect(slug) + try: + conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {col}") + except sqlite3.OperationalError: + pass + conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,)) + conn.commit() + conn.close() diff --git a/db/definitions/ensure_field_bounds_schema.py b/db/definitions/ensure_field_bounds_schema.py new file mode 100644 index 00000000..b5a015f7 --- /dev/null +++ b/db/definitions/ensure_field_bounds_schema.py @@ -0,0 +1,16 @@ +from ..connection import connect + + +def ensure_field_bounds_schema(slug): + """Migration légère (voir screens/screens_repo/ensure_schema.py pour le + même principe) : ajoute les colonnes min_value/max_value à _fields pour + les jeux créés avant le bornage automatique (2.2) — aucune migration + manuelle nécessaire, la prochaine lecture d'une définition suffit.""" + conn = connect(slug) + cols = {r["name"] for r in conn.execute("PRAGMA table_info(_fields)").fetchall()} + if "min_value" not in cols: + conn.execute("ALTER TABLE _fields ADD COLUMN min_value REAL") + if "max_value" not in cols: + conn.execute("ALTER TABLE _fields ADD COLUMN max_value REAL") + conn.commit() + conn.close() diff --git a/db/definitions/get_definition.py b/db/definitions/get_definition.py new file mode 100644 index 00000000..c149774d --- /dev/null +++ b/db/definitions/get_definition.py @@ -0,0 +1,18 @@ +from ..connection import connect +from .ensure_field_bounds_schema import ensure_field_bounds_schema + + +def get_definition(slug, definition_id): + ensure_field_bounds_schema(slug) + conn = connect(slug) + d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone() + if not d: + conn.close() + return None + fields = conn.execute( + "SELECT * FROM _fields WHERE definition_id = ? ORDER BY position, id", (definition_id,) + ).fetchall() + conn.close() + d = dict(d) + d["fields"] = [dict(f) for f in fields] + return d diff --git a/db/definitions/list_definitions.py b/db/definitions/list_definitions.py new file mode 100644 index 00000000..ed28d9fd --- /dev/null +++ b/db/definitions/list_definitions.py @@ -0,0 +1,8 @@ +from ..connection import connect + + +def list_definitions(slug): + conn = connect(slug) + rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/db/definitions/rename_definition.py b/db/definitions/rename_definition.py new file mode 100644 index 00000000..465b3787 --- /dev/null +++ b/db/definitions/rename_definition.py @@ -0,0 +1,8 @@ +from ..connection import connect + + +def rename_definition(slug, definition_id, new_name): + conn = connect(slug) + conn.execute("UPDATE _definitions SET name = ? WHERE id = ?", (new_name, definition_id)) + conn.commit() + conn.close() diff --git a/db/definitions/update_field.py b/db/definitions/update_field.py new file mode 100644 index 00000000..7a8fc93a --- /dev/null +++ b/db/definitions/update_field.py @@ -0,0 +1,48 @@ +import sqlite3 + +from ..connection import connect +from ..slugify import slugify +from .get_definition import get_definition + + +def update_field(slug, definition_id, field_id, new_name, required, relation_definition_id=None, min_value=None, max_value=None): + """CRUD — Update d'une définition : modifie un champ déjà créé (nom, + obligatoire, objet lié si c'est une relation, et bornes mini/maxi si + c'est un champ numérique — voir 2.2, bornage automatique). Si le nom + change, la vraie colonne SQL est renommée (ALTER TABLE ... RENAME + COLUMN, SQLite ≥ 3.25) pour que la colonne réelle continue de + correspondre exactement au nom du champ tel qu'affiché — pas de dérive + entre la définition et la table. Le TYPE d'un champ existant ne se + change pas ici (une vraie conversion de type SQLite demanderait de + reconstruire la table et de convertir les données déjà enregistrées, + hors scope de cette version).""" + definition = get_definition(slug, definition_id) + field = next((f for f in definition["fields"] if f["id"] == field_id), None) + if not field: + return + + old_col = slugify(field["name"]).replace("-", "_") + new_col = slugify(new_name).replace("-", "_") + if field["type"] == "relation": + old_col += "_id" + new_col += "_id" + + conn = connect(slug) + if old_col != new_col: + try: + conn.execute( + f"ALTER TABLE {definition['table_name']} RENAME COLUMN {old_col} TO {new_col}" + ) + except sqlite3.OperationalError: + pass # SQLite trop ancien pour RENAME COLUMN : la colonne SQL garde son ancien nom + + rel_id = int(relation_definition_id) if (field["type"] == "relation" and relation_definition_id) else field["relation_definition_id"] + is_numeric = field["type"] in ("nombre_entier", "nombre_decimal") + min_v = (min_value if min_value not in (None, "") else None) if is_numeric else None + max_v = (max_value if max_value not in (None, "") else None) if is_numeric else None + conn.execute( + "UPDATE _fields SET name = ?, required = ?, relation_definition_id = ?, min_value = ?, max_value = ? WHERE id = ?", + (new_name, 1 if required else 0, rel_id, min_v, max_v, field_id), + ) + conn.commit() + conn.close() diff --git a/db/game_dir.py b/db/game_dir.py new file mode 100644 index 00000000..bfc8d731 --- /dev/null +++ b/db/game_dir.py @@ -0,0 +1,7 @@ +import os + +from .constants import PROJECTS_DIR + + +def game_dir(slug): + return os.path.join(PROJECTS_DIR, slug) diff --git a/db/games/__init__.py b/db/games/__init__.py new file mode 100644 index 00000000..8110654e --- /dev/null +++ b/db/games/__init__.py @@ -0,0 +1 @@ +"""Cycle de vie d'un jeu (création, méta, suppression).""" diff --git a/db/games/create_game.py b/db/games/create_game.py new file mode 100644 index 00000000..570bb032 --- /dev/null +++ b/db/games/create_game.py @@ -0,0 +1,77 @@ +import os + +from ..connection import connect +from ..game_dir import game_dir +from ..slugify import slugify + + +def create_game(name): + """Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js + reliés entre eux, et sa base de données dédiée (nom du jeu en méta).""" + slug = slugify(name) + base = slug + n = 2 + while os.path.isdir(game_dir(base)): + base = f"{slug}-{n}" + n += 1 + slug = base + + os.makedirs(game_dir(slug), exist_ok=True) + + index_html = f"""<!DOCTYPE html> +<html lang="fr"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>{name} + + + +

{name}

+

Squelette généré par le moteur Forge — à remplir au fur et à mesure + des fonctionnalités du jeu.

+ + + +""" + index_css = """body { + font-family: system-ui, sans-serif; + margin: 40px; + color: #222; +} +""" + index_js = """// index.js — point d'entrée du jeu, généré par le moteur Forge. +console.log("Jeu chargé."); +""" + with open(os.path.join(game_dir(slug), "index.html"), "w", encoding="utf-8") as f: + f.write(index_html) + with open(os.path.join(game_dir(slug), "index.css"), "w", encoding="utf-8") as f: + f.write(index_css) + with open(os.path.join(game_dir(slug), "index.js"), "w", encoding="utf-8") as f: + f.write(index_js) + + conn = connect(slug) + conn.executescript( + """ + CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE _definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + table_name TEXT NOT NULL UNIQUE, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE _fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + definition_id INTEGER NOT NULL REFERENCES _definitions(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL, + relation_definition_id INTEGER REFERENCES _definitions(id), + required INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0 + ); + """ + ) + conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,)) + conn.commit() + conn.close() + return slug diff --git a/db/games/delete_game.py b/db/games/delete_game.py new file mode 100644 index 00000000..48be17e9 --- /dev/null +++ b/db/games/delete_game.py @@ -0,0 +1,7 @@ +import shutil + +from ..game_dir import game_dir + + +def delete_game(slug): + shutil.rmtree(game_dir(slug)) diff --git a/db/games/game_meta.py b/db/games/game_meta.py new file mode 100644 index 00000000..3d806029 --- /dev/null +++ b/db/games/game_meta.py @@ -0,0 +1,8 @@ +from ..connection import connect + + +def game_meta(slug): + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() + conn.close() + return {"slug": slug, "name": row["value"] if row else slug} diff --git a/db/games/list_games.py b/db/games/list_games.py new file mode 100644 index 00000000..656bf76d --- /dev/null +++ b/db/games/list_games.py @@ -0,0 +1,20 @@ +import os + +from ..constants import PROJECTS_DIR +from ..connection import connect +from ..db_path import db_path + + +def list_games(): + """Scanne le dossier projects/ : chaque sous-dossier avec un game.db est un jeu.""" + games = [] + if not os.path.isdir(PROJECTS_DIR): + return games + for slug in sorted(os.listdir(PROJECTS_DIR)): + path = db_path(slug) + if os.path.isfile(path): + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() + conn.close() + games.append({"slug": slug, "name": row["value"] if row else slug}) + return games diff --git a/db/games/update_game_name.py b/db/games/update_game_name.py new file mode 100644 index 00000000..404d5398 --- /dev/null +++ b/db/games/update_game_name.py @@ -0,0 +1,8 @@ +from ..connection import connect + + +def update_game_name(slug, new_name): + conn = connect(slug) + conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,)) + conn.commit() + conn.close() diff --git a/db/rows/__init__.py b/db/rows/__init__.py new file mode 100644 index 00000000..3169616e --- /dev/null +++ b/db/rows/__init__.py @@ -0,0 +1 @@ +"""Lignes de données (CRUD) pour les objets définis dans un jeu.""" diff --git a/db/rows/delete_row.py b/db/rows/delete_row.py new file mode 100644 index 00000000..8f444a74 --- /dev/null +++ b/db/rows/delete_row.py @@ -0,0 +1,8 @@ +from ..connection import connect + + +def delete_row(slug, definition, row_id): + conn = connect(slug) + conn.execute(f"DELETE FROM {definition['table_name']} WHERE id = ?", (row_id,)) + conn.commit() + conn.close() diff --git a/db/rows/get_row.py b/db/rows/get_row.py new file mode 100644 index 00000000..1758bb3a --- /dev/null +++ b/db/rows/get_row.py @@ -0,0 +1,10 @@ +from ..connection import connect + + +def get_row(slug, definition, row_id): + conn = connect(slug) + row = conn.execute( + f"SELECT * FROM {definition['table_name']} WHERE id = ?", (row_id,) + ).fetchone() + conn.close() + return dict(row) if row else None diff --git a/db/rows/insert_row.py b/db/rows/insert_row.py new file mode 100644 index 00000000..5464ce34 --- /dev/null +++ b/db/rows/insert_row.py @@ -0,0 +1,14 @@ +from ..connection import connect +from .row_columns_and_values import row_columns_and_values + + +def insert_row(slug, definition, form_data): + """Feature 3 : enregistre en base les données saisies dans le formulaire + généré à partir de la définition d'objet.""" + cols, values = row_columns_and_values(definition, form_data) + conn = connect(slug) + placeholders = ["?"] * len(cols) + sql = f"INSERT INTO {definition['table_name']} ({', '.join(cols)}) VALUES ({', '.join(placeholders)})" + conn.execute(sql, values) + conn.commit() + conn.close() diff --git a/db/rows/list_rows.py b/db/rows/list_rows.py new file mode 100644 index 00000000..78e2fb26 --- /dev/null +++ b/db/rows/list_rows.py @@ -0,0 +1,8 @@ +from ..connection import connect + + +def list_rows(slug, definition): + conn = connect(slug) + rows = conn.execute(f"SELECT * FROM {definition['table_name']} ORDER BY id DESC").fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/db/rows/relation_options.py b/db/rows/relation_options.py new file mode 100644 index 00000000..5a3a9c53 --- /dev/null +++ b/db/rows/relation_options.py @@ -0,0 +1,19 @@ +from ..connection import connect +from ..slugify import slugify +from ..definitions.get_definition import get_definition + + +def relation_options(slug, related_definition_id): + """Lignes existantes d'un objet lié, pour peupler un + # à 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, + ) diff --git a/routes/objects/data_new.py b/routes/objects/data_new.py new file mode 100644 index 00000000..ccd2f97a --- /dev/null +++ b/routes/objects/data_new.py @@ -0,0 +1,7 @@ +from core.flask_app import app +from .data_form import _data_form + + +@app.route("/game//objects//data/new", methods=["GET", "POST"]) +def data_new(slug, definition_id): + return _data_form(slug, definition_id, row=None) diff --git a/routes/objects/object_delete.py b/routes/objects/object_delete.py new file mode 100644 index 00000000..1a087a38 --- /dev/null +++ b/routes/objects/object_delete.py @@ -0,0 +1,18 @@ +from flask import redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//objects//delete", methods=["POST"]) +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)) + db.delete_definition(slug, definition_id) + return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/objects/object_edit.py b/routes/objects/object_edit.py new file mode 100644 index 00000000..d96d1ea1 --- /dev/null +++ b/routes/objects/object_edit.py @@ -0,0 +1,28 @@ +from flask import render_template, request, redirect, url_for, abort + +import db + +from core.flask_app import app + + +@app.route("/game//objects//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] + + if request.method == "POST": + 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, + ) diff --git a/routes/objects/object_field_add.py b/routes/objects/object_field_add.py new file mode 100644 index 00000000..49a6afd7 --- /dev/null +++ b/routes/objects/object_field_add.py @@ -0,0 +1,20 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//objects//fields/add", methods=["POST"]) +def object_field_add(slug, definition_id): + name = request.form.get("field_name", "").strip() + if name: + db.add_field_to_definition(slug, definition_id, { + "name": name, + "type": request.form.get("field_type", "texte"), + "relation_definition_id": request.form.get("field_relation"), + "required": request.form.get("field_required") == "1", + "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)) diff --git a/routes/objects/object_field_delete.py b/routes/objects/object_field_delete.py new file mode 100644 index 00000000..4e2a719a --- /dev/null +++ b/routes/objects/object_field_delete.py @@ -0,0 +1,11 @@ +from flask import redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//objects//fields//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)) diff --git a/routes/objects/object_field_edit.py b/routes/objects/object_field_edit.py new file mode 100644 index 00000000..b77c799d --- /dev/null +++ b/routes/objects/object_field_edit.py @@ -0,0 +1,20 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//objects//fields//edit", methods=["POST"]) +def object_field_edit(slug, definition_id, field_id): + new_name = request.form.get("field_name", "").strip() + if new_name: + db.update_field( + slug, definition_id, field_id, + new_name=new_name, + required=request.form.get("field_required") == "1", + relation_definition_id=request.form.get("field_relation"), + 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)) diff --git a/routes/objects/object_new.py b/routes/objects/object_new.py new file mode 100644 index 00000000..6ee7fd29 --- /dev/null +++ b/routes/objects/object_new.py @@ -0,0 +1,27 @@ +from flask import render_template, request, redirect, url_for + +import db + +from core.flask_app import app +from .parse_field_rows import _parse_field_rows + + +@app.route("/game//objects/new", methods=["GET", "POST"]) +def object_new(slug): + game = db.game_meta(slug) + existing_definitions = db.list_definitions(slug) + + 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, + ) diff --git a/routes/objects/object_view.py b/routes/objects/object_view.py new file mode 100644 index 00000000..52474d36 --- /dev/null +++ b/routes/objects/object_view.py @@ -0,0 +1,33 @@ +from flask import render_template, request, abort + +import db + +from core.flask_app import app + + +@app.route("/game//objects/") +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, + ) diff --git a/routes/objects/parse_field_rows.py b/routes/objects/parse_field_rows.py new file mode 100644 index 00000000..c0a9b106 --- /dev/null +++ b/routes/objects/parse_field_rows.py @@ -0,0 +1,20 @@ +def _parse_field_rows(form): + field_names = form.getlist("field_name[]") + field_types = form.getlist("field_type[]") + field_relations = form.getlist("field_relation[]") + field_required = form.getlist("field_required[]") + field_min = form.getlist("field_min[]") + field_max = form.getlist("field_max[]") + fields = [] + for i, fname in enumerate(field_names): + if not fname.strip(): + continue + fields.append({ + "name": fname.strip(), + "type": field_types[i], + "relation_definition_id": field_relations[i] if i < len(field_relations) else None, + "required": field_required[i] == "1" if i < len(field_required) else False, + "min_value": (field_min[i].strip() or None) if i < len(field_min) else None, + "max_value": (field_max[i].strip() or None) if i < len(field_max) else None, + }) + return fields diff --git a/routes/play/__init__.py b/routes/play/__init__.py new file mode 100644 index 00000000..a6c3d3a5 --- /dev/null +++ b/routes/play/__init__.py @@ -0,0 +1 @@ +from . import game_play diff --git a/routes/play/game_play.py b/routes/play/game_play.py new file mode 100644 index 00000000..59311ce9 --- /dev/null +++ b/routes/play/game_play.py @@ -0,0 +1,18 @@ +import json + +from flask import render_template + +import db +import screens + +from core.flask_app import app + + +@app.route("/game//play") +def game_play(slug): + game = db.game_meta(slug) + payload = screens.full_game_payload(slug) + return render_template( + "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + google_fonts_link=screens.GOOGLE_FONTS_LINK, + ) diff --git a/routes/screens/__init__.py b/routes/screens/__init__.py new file mode 100644 index 00000000..5c8c5f25 --- /dev/null +++ b/routes/screens/__init__.py @@ -0,0 +1 @@ +from . import screens_list, screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect diff --git a/routes/screens/screen_delete.py b/routes/screens/screen_delete.py new file mode 100644 index 00000000..880a5d32 --- /dev/null +++ b/routes/screens/screen_delete.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//delete", methods=["POST"]) +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)) + screens.delete_screen(slug, screen_id) + return redirect(url_for("screens_list", slug=slug)) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py new file mode 100644 index 00000000..6e994def --- /dev/null +++ b/routes/screens/screen_edit.py @@ -0,0 +1,90 @@ +import json + +from flask import render_template, request, abort + +import db +import screens + +from core.flask_app import app + + +@app.route("/game//screens//edit") +def screen_edit(slug, screen_id): + game = db.game_meta(slug) + screen = screens.get_screen(slug, screen_id) + if not screen: + abort(404) + elements = screens.list_elements(slug, screen_id) + all_screens = screens.list_screens(slug) + element_types = screens.list_element_types(slug) + selected_id = request.args.get("selected", type=int) + selected_controls = None + if selected_id: + selected_el = next((e for e in elements if e["id"] == selected_id), None) + if selected_el: + selected_controls = screens.controls_with_values(slug, selected_el) + children_of = {} + for el in elements: + if el.get("parent_id"): + children_of.setdefault(el["parent_id"], []).append(el) + top_level_elements = [el for el in elements if not el.get("parent_id")] + elements_by_id = {el["id"]: el for el in elements} + + definitions = db.list_definitions(slug) + definitions_data = {} + for d in definitions: + full = db.get_definition(slug, d["id"]) + rows = db.list_rows(slug, full) + display_field = full["fields"][0]["name"] if full["fields"] else None + display_col = db.slugify(display_field).replace("-", "_") if display_field else None + row_options = [ + { + "id": r["id"], + "label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}", + } + for r in rows + ] + field_options = [{"name": f["name"], "type": f["type"]} for f in full["fields"] if f["type"] != "relation"] + definitions_data[str(d["id"])] = {"rows": row_options, "fields": field_options} + + flow_nodes = screens.list_flow_nodes(slug, screen_id) + flow_edges = screens.list_flow_edges(slug, screen_id) + elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements} + screens_map = {s["id"]: s["name"] for s in all_screens} + animation_clips = screens.list_animation_clips(slug, screen_id) + + return render_template( + "screen_edit.html", + game=game, screen=screen, elements=elements, top_level_elements=top_level_elements, + children_of=children_of, container_widgets=screens.CONTAINER_WIDGETS, + elements_by_id=elements_by_id, + all_screens=all_screens, + element_types=element_types, widgets=screens.WIDGETS, + trigger_labels=screens.TRIGGER_LABELS, action_type_labels=screens.ACTION_TYPE_LABELS, + element_action_properties=screens.ELEMENT_ACTION_PROPERTIES, + element_action_property_labels=screens.ELEMENT_ACTION_PROPERTY_LABELS, + element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES, + element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS, + element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES, + element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES, + data_operations=screens.DATA_OPERATIONS, + data_operation_labels=screens.DATA_OPERATION_LABELS, + definitions=definitions, definitions_json=json.dumps(definitions_data), + selected_id=selected_id, selected_controls=selected_controls, + google_fonts_link=screens.GOOGLE_FONTS_LINK, + flow_nodes=flow_nodes, flow_edges=flow_edges, + flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges), + trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS, + elements_labels_json=json.dumps(elements_labels), screens_map_json=json.dumps(screens_map), + action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS), + element_action_property_labels_json=json.dumps(screens.ELEMENT_ACTION_PROPERTY_LABELS), + element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS), + element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS), + element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_LABELS), + data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS), + condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS), + trigger_labels_json=json.dumps(screens.TRIGGER_LABELS), + animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips), + animate_css_catalog=screens.ANIMATE_CSS_CATALOG, + animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), + ) diff --git a/routes/screens/screen_move.py b/routes/screens/screen_move.py new file mode 100644 index 00000000..8a9d920c --- /dev/null +++ b/routes/screens/screen_move.py @@ -0,0 +1,12 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//move", methods=["POST"]) +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)) diff --git a/routes/screens/screen_rename.py b/routes/screens/screen_rename.py new file mode 100644 index 00000000..8302151b --- /dev/null +++ b/routes/screens/screen_rename.py @@ -0,0 +1,13 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//rename", methods=["POST"]) +def screen_rename(slug, screen_id): + new_name = request.form.get("name", "").strip() + if new_name: + screens.rename_screen(slug, screen_id, new_name) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) diff --git a/routes/screens/screen_set_aspect.py b/routes/screens/screen_set_aspect.py new file mode 100644 index 00000000..d433cb95 --- /dev/null +++ b/routes/screens/screen_set_aspect.py @@ -0,0 +1,17 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//aspect", methods=["POST"]) +def screen_set_aspect(slug, screen_id): + """Mémorise le format d'aperçu (Portrait/Paysage/Carré) choisi pour cet + écran, en AJAX — sans recharger la page, pour ne jamais perdre ce choix + au prochain ajout/suppression d'élément ou changement de sélection (qui + redemandent le panneau au serveur).""" + body = request.get_json(silent=True) or {} + aspect = body.get("aspect", "") + screens.set_screen_aspect(slug, screen_id, aspect) + return jsonify({"ok": True}) diff --git a/routes/screens/screens_list.py b/routes/screens/screens_list.py new file mode 100644 index 00000000..c3e2b093 --- /dev/null +++ b/routes/screens/screens_list.py @@ -0,0 +1,12 @@ +from flask import render_template + +import db +import screens + +from core.flask_app import app + + +@app.route("/game//screens") +def screens_list(slug): + game = db.game_meta(slug) + return render_template("screens_list.html", game=game, screen_list=screens.list_screens(slug)) diff --git a/routes/screens/screens_new.py b/routes/screens/screens_new.py new file mode 100644 index 00000000..4eda95dc --- /dev/null +++ b/routes/screens/screens_new.py @@ -0,0 +1,14 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens/new", methods=["POST"]) +def screens_new(slug): + name = request.form.get("name", "").strip() + 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)) diff --git a/routes/uploads/__init__.py b/routes/uploads/__init__.py new file mode 100644 index 00000000..86fe3343 --- /dev/null +++ b/routes/uploads/__init__.py @@ -0,0 +1 @@ +from . import upload_file, uploaded_file diff --git a/routes/uploads/upload_file.py b/routes/uploads/upload_file.py new file mode 100644 index 00000000..55f77097 --- /dev/null +++ b/routes/uploads/upload_file.py @@ -0,0 +1,25 @@ +import os +import uuid + +from flask import request, jsonify, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//upload", methods=["POST"]) +def upload_file(slug): + """Utilisé par les réglages Image/Vidéo pour héberger un fichier + envoyé par l'utilisateur (au lieu d'une simple adresse URL) — le + fichier est stocké dans le dossier du jeu, et l'adresse renvoyée est + ensuite utilisée exactement comme une URL normale.""" + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"error": "Aucun fichier reçu"}), 400 + ext = "".join(c for c in os.path.splitext(f.filename)[1].lower() if c.isalnum() or c == ".")[:10] + filename = uuid.uuid4().hex + ext + upload_dir = os.path.join(db.game_dir(slug), "uploads") + os.makedirs(upload_dir, exist_ok=True) + f.save(os.path.join(upload_dir, filename)) + return jsonify({"url": url_for("uploaded_file", slug=slug, filename=filename)}) diff --git a/routes/uploads/uploaded_file.py b/routes/uploads/uploaded_file.py new file mode 100644 index 00000000..2e4d0378 --- /dev/null +++ b/routes/uploads/uploaded_file.py @@ -0,0 +1,12 @@ +import os + +from flask import send_from_directory + +import db + +from core.flask_app import app + + +@app.route("/game//uploads/") +def uploaded_file(slug, filename): + return send_from_directory(os.path.join(db.game_dir(slug), "uploads"), filename) diff --git a/screens/__init__.py b/screens/__init__.py new file mode 100644 index 00000000..5313fb71 --- /dev/null +++ b/screens/__init__.py @@ -0,0 +1,113 @@ +""" +screens.py — écrans de jeu : éléments posés en glisser-déposer sur un +écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans, +et actions déclenchées par clic ou soumission de formulaire. + +Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de +WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de +données...), chacun avec ses propres réglages ("controls") : un réglage = +un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom +de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget +correspond à une vraie balise HTML avec de vrais attributs et un vrai +style CSS ; l'utilisateur ne les manipule jamais directement. + +Tout est stocké dans la même base SQLite que le reste du jeu +(projects//game.db) : + - _screens : un écran du jeu (accueil, formulaire, scène...) + - _element_types : le "catalogue" des éléments de jeu réutilisables + (Personnage, Outil, IA...) que l'utilisateur crée + - _screen_elements : chaque élément posé sur un écran (widget, position + et taille en POURCENTAGE de l'écran, contenu, + attributs et style — dérivés des réglages du + widget, jamais saisis en texte libre) + - _actions : ce qui se passe au clic / à la soumission d'un + élément (écran suivant, écran précédent, aller à + un écran précis, ne rien faire) + +Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu +fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche +automatiquement une ligne par donnée existante — par exemple, tous les +niveaux d'un parcours — à partir d'un modèle de texte avec des +{{nom_de_champ}}. + +Ce fichier est désormais un package (voir les sous-dossiers) organisé +selon "un fichier = une fonction, un dossier = une responsabilité" ; ce +module se contente de ré-exporter l'intégralité de l'API publique +utilisée par app.py et les templates, afin que `import screens` continue +de fonctionner à l'identique. +""" + +from .widgets.registry import WIDGETS +from .widgets.container_widgets import CONTAINER_WIDGETS +from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS +from .widgets.font_options import GOOGLE_FONTS_LINK +from .widgets.widget_meta import widget_meta +from .widgets.controls_with_values import controls_with_values + +from .labels.trigger_labels import TRIGGER_LABELS +from .labels.action_type_labels import ACTION_TYPE_LABELS +from .labels.element_action_properties import ( + ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS, + ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS, + ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS, +) +from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS +from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS + +from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS + +from .rendering.render_element_html import render_element_html +from .rendering.trigger_for import _trigger_for + +from .screens_repo.ensure_schema import ensure_schema +from .screens_repo.list_screens import list_screens +from .screens_repo.get_screen import get_screen +from .screens_repo.create_screen import create_screen +from .screens_repo.rename_screen import rename_screen +from .screens_repo.delete_screen import delete_screen +from .screens_repo.move_screen import move_screen +from .screens_repo.screens_referencing import screens_referencing +from .screens_repo.set_screen_aspect import set_screen_aspect + +from .elements.list_elements import list_elements +from .elements.get_element import get_element +from .elements.add_element import add_element +from .elements.update_element_geometry import update_element_geometry +from .elements.save_element_controls import save_element_controls +from .elements.delete_element import delete_element +from .elements.rename_element import rename_element +from .elements.element_label import element_label + +from .element_types.list_element_types import list_element_types +from .element_types.create_element_type import create_element_type +from .element_types.update_element_type import update_element_type +from .element_types.element_type_usage_count import element_type_usage_count +from .element_types.delete_element_type import delete_element_type +from .element_types.instantiate_template_tree import instantiate_template_tree + +from .legacy_actions.list_actions import list_actions +from .legacy_actions.add_action import add_action +from .legacy_actions.get_action import get_action +from .legacy_actions.delete_action import delete_action + +from .flow.ensure_flow_schema import ensure_flow_schema +from .flow.list_flow_nodes import list_flow_nodes +from .flow.list_flow_edges import list_flow_edges +from .flow.get_flow_node import get_flow_node +from .flow.add_flow_node import add_flow_node +from .flow.move_flow_node import move_flow_node +from .flow.delete_flow_node import delete_flow_node +from .flow.add_flow_edge import add_flow_edge +from .flow.delete_flow_edge import delete_flow_edge + +from .data_actions.apply_data_action import apply_data_action + +from .animations.ensure_animation_schema import ensure_animation_schema +from .animations.list_animation_clips import list_animation_clips +from .animations.get_animation_clip import get_animation_clip +from .animations.add_animation_clip import add_animation_clip +from .animations.update_animation_clip import update_animation_clip +from .animations.delete_animation_clip import delete_animation_clip +from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS + +from .payload.full_game_payload import full_game_payload diff --git a/screens/animations/__init__.py b/screens/animations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/screens/animations/add_animation_clip.py b/screens/animations/add_animation_clip.py new file mode 100644 index 00000000..54291ea7 --- /dev/null +++ b/screens/animations/add_animation_clip.py @@ -0,0 +1,37 @@ +import json + +import db + +from .ensure_animation_schema import ensure_animation_schema + +# Point de départ raisonnable pour un nouveau clip "personnalisé" : un +# fondu simple de 0 à 1, que le créateur du jeu peut ensuite modifier ou +# remplacer entièrement dans l'éditeur de images-clés. +_DEFAULT_CUSTOM_KEYFRAMES = [ + {"percent": 0, "props": {"opacity": "0"}}, + {"percent": 100, "props": {"opacity": "1"}}, +] + + +def add_animation_clip(slug, screen_id, element_id, kind="animate_css", animate_name=None, + custom_keyframes=None, start_time=0, duration=1, delay=0, + easing="ease", iteration_count="1"): + ensure_animation_schema(slug) + if kind == "custom" and not custom_keyframes: + custom_keyframes = _DEFAULT_CUSTOM_KEYFRAMES + if kind == "animate_css" and not animate_name: + animate_name = "fadeIn" + keyframes_json = json.dumps(custom_keyframes) if custom_keyframes else None + conn = db.connect(slug) + conn.execute( + """INSERT INTO _animation_clips + (screen_id, element_id, kind, animate_name, custom_keyframes, + start_time, duration, delay, easing, iteration_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (screen_id, element_id, kind, animate_name, keyframes_json, + float(start_time), float(duration), float(delay), easing, str(iteration_count)), + ) + clip_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return clip_id diff --git a/screens/animations/animate_css_catalog.py b/screens/animations/animate_css_catalog.py new file mode 100644 index 00000000..4749254f --- /dev/null +++ b/screens/animations/animate_css_catalog.py @@ -0,0 +1,76 @@ +# Catalogue des animations de la bibliothèque Animate.css (v4), chargée +# depuis un CDN dans l'éditeur et en mode jouable (voir extra_head de +# screen_edit.html et le de play.html) — chaque entrée correspond +# exactement à une classe "animate__xxx" fournie par la bibliothèque, posée +# telle quelle sur l'élément ciblé (voir applyAnimationClip() dans +# templates/play.html). Regroupées par catégorie pour un menu déroulant +# plus lisible que 80 entrées à plat. +ANIMATE_CSS_CATALOG = [ + ("Attirer l'attention", [ + ("bounce", "Rebond"), ("flash", "Flash"), ("pulse", "Pulsation"), + ("rubberBand", "Élastique"), ("shakeX", "Secousse horizontale"), + ("shakeY", "Secousse verticale"), ("headShake", "Hoche la tête"), + ("swing", "Balancement"), ("tada", "Tada"), ("wobble", "Vacillement"), + ("jello", "Gélatine"), ("heartBeat", "Battement de cœur"), + ]), + ("Entrée — fondu", [ + ("fadeIn", "Apparition"), ("fadeInDown", "Apparition depuis le haut"), + ("fadeInLeft", "Apparition depuis la gauche"), ("fadeInRight", "Apparition depuis la droite"), + ("fadeInUp", "Apparition depuis le bas"), + ]), + ("Sortie — fondu", [ + ("fadeOut", "Disparition"), ("fadeOutDown", "Disparition vers le bas"), + ("fadeOutLeft", "Disparition vers la gauche"), ("fadeOutRight", "Disparition vers la droite"), + ("fadeOutUp", "Disparition vers le haut"), + ]), + ("Entrée — rebond", [ + ("bounceIn", "Rebond entrant"), ("bounceInDown", "Rebond depuis le haut"), + ("bounceInLeft", "Rebond depuis la gauche"), ("bounceInRight", "Rebond depuis la droite"), + ("bounceInUp", "Rebond depuis le bas"), + ]), + ("Sortie — rebond", [ + ("bounceOut", "Rebond sortant"), ("bounceOutDown", "Rebond vers le bas"), + ("bounceOutLeft", "Rebond vers la gauche"), ("bounceOutRight", "Rebond vers la droite"), + ("bounceOutUp", "Rebond vers le haut"), + ]), + ("Entrée — glissement", [ + ("slideInDown", "Glisse depuis le haut"), ("slideInLeft", "Glisse depuis la gauche"), + ("slideInRight", "Glisse depuis la droite"), ("slideInUp", "Glisse depuis le bas"), + ]), + ("Sortie — glissement", [ + ("slideOutDown", "Glisse vers le bas"), ("slideOutLeft", "Glisse vers la gauche"), + ("slideOutRight", "Glisse vers la droite"), ("slideOutUp", "Glisse vers le haut"), + ]), + ("Entrée — zoom", [ + ("zoomIn", "Zoom entrant"), ("zoomInDown", "Zoom depuis le haut"), + ("zoomInLeft", "Zoom depuis la gauche"), ("zoomInRight", "Zoom depuis la droite"), + ("zoomInUp", "Zoom depuis le bas"), + ]), + ("Sortie — zoom", [ + ("zoomOut", "Zoom sortant"), ("zoomOutDown", "Zoom vers le bas"), + ("zoomOutLeft", "Zoom vers la gauche"), ("zoomOutRight", "Zoom vers la droite"), + ("zoomOutUp", "Zoom vers le haut"), + ]), + ("Rotation", [ + ("rotateIn", "Rotation entrante"), ("rotateOut", "Rotation sortante"), + ("rotateInDownLeft", "Rotation depuis haut-gauche"), ("rotateInDownRight", "Rotation depuis haut-droite"), + ("rotateInUpLeft", "Rotation depuis bas-gauche"), ("rotateInUpRight", "Rotation depuis bas-droite"), + ]), + ("Retournement", [ + ("flip", "Retournement"), ("flipInX", "Retournement horizontal entrant"), + ("flipInY", "Retournement vertical entrant"), ("flipOutX", "Retournement horizontal sortant"), + ("flipOutY", "Retournement vertical sortant"), + ]), + ("Vitesse lumière", [ + ("lightSpeedInRight", "Entrée vitesse lumière — droite"), ("lightSpeedInLeft", "Entrée vitesse lumière — gauche"), + ("lightSpeedOutRight", "Sortie vitesse lumière — droite"), ("lightSpeedOutLeft", "Sortie vitesse lumière — gauche"), + ]), + ("Spéciales", [ + ("jackInTheBox", "Boîte à surprise"), ("rollIn", "Roule à l'entrée"), ("rollOut", "Roule à la sortie"), + ("backInDown", "Recul entrant — haut"), ("backInUp", "Recul entrant — bas"), + ("backOutDown", "Recul sortant — bas"), ("backOutUp", "Recul sortant — haut"), + ]), +] + +# Map key -> label, pour l'affichage côté éditeur (nom du nœud, etc.). +ANIMATE_CSS_LABELS = {key: label for _cat, items in ANIMATE_CSS_CATALOG for key, label in items} diff --git a/screens/animations/delete_animation_clip.py b/screens/animations/delete_animation_clip.py new file mode 100644 index 00000000..6f2ff656 --- /dev/null +++ b/screens/animations/delete_animation_clip.py @@ -0,0 +1,8 @@ +import db + + +def delete_animation_clip(slug, clip_id): + conn = db.connect(slug) + conn.execute("DELETE FROM _animation_clips WHERE id = ?", (clip_id,)) + conn.commit() + conn.close() diff --git a/screens/animations/ensure_animation_schema.py b/screens/animations/ensure_animation_schema.py new file mode 100644 index 00000000..10b5bb80 --- /dev/null +++ b/screens/animations/ensure_animation_schema.py @@ -0,0 +1,29 @@ +import db + + +def ensure_animation_schema(slug): + """Crée la table des clips d'animation si elle n'existe pas encore — + même principe que ensure_flow_schema.py : un jeu créé avant l'ajout de + cette fonctionnalité n'a pas besoin d'être migré à la main, la première + ouverture du panneau timeline suffit.""" + conn = db.connect(slug) + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS _animation_clips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + element_id INTEGER NOT NULL REFERENCES _screen_elements(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'animate_css', + animate_name TEXT, + custom_keyframes TEXT, + start_time REAL NOT NULL DEFAULT 0, + duration REAL NOT NULL DEFAULT 1, + delay REAL NOT NULL DEFAULT 0, + easing TEXT NOT NULL DEFAULT 'ease', + iteration_count TEXT NOT NULL DEFAULT '1', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/screens/animations/get_animation_clip.py b/screens/animations/get_animation_clip.py new file mode 100644 index 00000000..0233895f --- /dev/null +++ b/screens/animations/get_animation_clip.py @@ -0,0 +1,11 @@ +import db + +from .ensure_animation_schema import ensure_animation_schema + + +def get_animation_clip(slug, clip_id): + ensure_animation_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _animation_clips WHERE id = ?", (clip_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/screens/animations/list_animation_clips.py b/screens/animations/list_animation_clips.py new file mode 100644 index 00000000..974ad247 --- /dev/null +++ b/screens/animations/list_animation_clips.py @@ -0,0 +1,13 @@ +import db + +from .ensure_animation_schema import ensure_animation_schema + + +def list_animation_clips(slug, screen_id): + ensure_animation_schema(slug) + conn = db.connect(slug) + rows = conn.execute( + "SELECT * FROM _animation_clips WHERE screen_id = ? ORDER BY start_time, id", (screen_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/animations/update_animation_clip.py b/screens/animations/update_animation_clip.py new file mode 100644 index 00000000..96b2c720 --- /dev/null +++ b/screens/animations/update_animation_clip.py @@ -0,0 +1,33 @@ +import json + +import db + +from .ensure_animation_schema import ensure_animation_schema + +# Champs modifiables après création — le déplacement/redimensionnement à la +# souris dans le panneau timeline n'envoie que start_time/duration, le +# formulaire d'édition du clip peut envoyer n'importe lequel des autres. +_UPDATABLE_FIELDS = { + "kind", "animate_name", "start_time", "duration", "delay", "easing", "iteration_count", +} + + +def update_animation_clip(slug, clip_id, **fields): + ensure_animation_schema(slug) + sets, vals = [], [] + for key, value in fields.items(): + if key == "custom_keyframes": + sets.append("custom_keyframes = ?") + vals.append(json.dumps(value) if value else None) + elif key in _UPDATABLE_FIELDS and value is not None: + if key in ("start_time", "duration", "delay"): + value = float(value) + sets.append(f"{key} = ?") + vals.append(value) + if not sets: + return + vals.append(clip_id) + conn = db.connect(slug) + conn.execute(f"UPDATE _animation_clips SET {', '.join(sets)} WHERE id = ?", vals) + conn.commit() + conn.close() diff --git a/screens/data_actions/__init__.py b/screens/data_actions/__init__.py new file mode 100644 index 00000000..cb683cf0 --- /dev/null +++ b/screens/data_actions/__init__.py @@ -0,0 +1 @@ +"""Application des actions "modifier une donnée d'objet" en mode jouable.""" diff --git a/screens/data_actions/apply_data_action.py b/screens/data_actions/apply_data_action.py new file mode 100644 index 00000000..8f32ae67 --- /dev/null +++ b/screens/data_actions/apply_data_action.py @@ -0,0 +1,76 @@ +import db + + +def apply_data_action(slug, action): + """Exécute au moment du clic (mode jouable) une action "modifier_donnee" : + lit l'objet/la ligne/le champ visés dans l'action, calcule la nouvelle + valeur selon l'opération choisie, et l'enregistre — un vrai UPDATE SQL, + limité à cette seule colonne (les autres champs de la ligne ne sont pas + touchés).""" + definition_id = action.get("target_definition_id") + row_id = action.get("target_row_id") + field_name = action.get("target_field") + operation = action.get("data_operation") + raw_value = action.get("data_value") + if not (definition_id and row_id and field_name and operation): + return False + definition = db.get_definition(slug, definition_id) + if not definition: + return False + field_def = next((f for f in definition["fields"] if f["name"] == field_name), None) + if not field_def: + return False + row = db.get_row(slug, definition, row_id) + if not row: + return False + col = db.slugify(field_def["name"]).replace("-", "_") + if field_def["type"] == "relation": + col = col + "_id" + current = row.get(col) + + if operation == "definir_texte": + new_value = raw_value or "" + elif operation == "definir_nombre": + try: + new_value = float(raw_value) if field_def["type"] == "nombre_decimal" else int(float(raw_value)) + except (TypeError, ValueError): + new_value = 0 + elif operation == "definir_bool_vrai": + new_value = 1 + elif operation == "definir_bool_faux": + new_value = 0 + elif operation == "basculer_bool": + new_value = 0 if current else 1 + elif operation in ("incrementer", "decrementer"): + try: + amount = float(raw_value) if raw_value not in (None, "") else 0 + except (TypeError, ValueError): + amount = 0 + if operation == "decrementer": + amount = -amount + result = (current or 0) + amount + new_value = result if field_def["type"] == "nombre_decimal" else int(result) + else: + return False + + new_value = _clamp_to_field_bounds(new_value, field_def) + db.update_row_field(slug, definition, row_id, field_def, new_value) + return True + + +def _clamp_to_field_bounds(value, field_def): + """2.2 — bornage automatique : ramène la valeur dans [min_value, + max_value] si l'un ou l'autre est réglé sur ce champ, pour qu'une + jauge (voir render_jauge.py) ne puisse jamais dépasser 100 % ni + descendre sous 0 % (ou toute autre borne définie), quel que soit le + nombre d'actions "Augmenter de..."/"Diminuer de..." posées dans le + graphe de logique. Ne s'applique qu'aux champs numériques — un champ + non numérique n'a pas de min_value/max_value (voir create_definition).""" + if field_def["type"] not in ("nombre_entier", "nombre_decimal"): + return value + min_v, max_v = field_def.get("min_value"), field_def.get("max_value") + if min_v is not None and value < min_v: + value = min_v + if max_v is not None and value > max_v: + value = max_v + return value if field_def["type"] == "nombre_decimal" else int(value) diff --git a/screens/element_types/__init__.py b/screens/element_types/__init__.py new file mode 100644 index 00000000..44558eab --- /dev/null +++ b/screens/element_types/__init__.py @@ -0,0 +1 @@ +"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...).""" diff --git a/screens/element_types/create_element_type.py b/screens/element_types/create_element_type.py new file mode 100644 index 00000000..f1e4a49e --- /dev/null +++ b/screens/element_types/create_element_type.py @@ -0,0 +1,21 @@ +import db + +from ..screens_repo.ensure_schema import ensure_schema +from ..screens_repo.create_screen import create_screen + + +def create_element_type(slug, name, icon, bound_definition_id=None): + """Un élément de jeu est un conteneur réutilisable : sa "recette" (les + éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle + caché, créé ici en même temps que l'entrée de catalogue — on le + remplit ensuite exactement comme le contenu de n'importe quel + conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle.""" + ensure_schema(slug) + template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True) + conn = db.connect(slug) + conn.execute( + "INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)", + (name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None), + ) + conn.commit() + conn.close() diff --git a/screens/element_types/delete_element_type.py b/screens/element_types/delete_element_type.py new file mode 100644 index 00000000..3e4c47b8 --- /dev/null +++ b/screens/element_types/delete_element_type.py @@ -0,0 +1,15 @@ +import db + + +def delete_element_type(slug, element_type_id): + conn = db.connect(slug) + row = conn.execute( + "SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,) + ).fetchone() + conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,)) + if row and row["template_screen_id"]: + # Supprime aussi l'écran-modèle caché associé — son propre contenu + # (_screen_elements) part avec lui via ON DELETE CASCADE. + conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],)) + conn.commit() + conn.close() diff --git a/screens/element_types/element_type_usage_count.py b/screens/element_types/element_type_usage_count.py new file mode 100644 index 00000000..acfc7533 --- /dev/null +++ b/screens/element_types/element_type_usage_count.py @@ -0,0 +1,10 @@ +import db + + +def element_type_usage_count(slug, element_type_id): + conn = db.connect(slug) + count = conn.execute( + "SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,) + ).fetchone()["c"] + conn.close() + return count diff --git a/screens/element_types/instantiate_template_tree.py b/screens/element_types/instantiate_template_tree.py new file mode 100644 index 00000000..f9d31382 --- /dev/null +++ b/screens/element_types/instantiate_template_tree.py @@ -0,0 +1,48 @@ +import db + + +def instantiate_template_tree(slug, template_screen_id, target_screen_id, target_parent_id=None): + """Copie EN PROFONDEUR le contenu d'un écran-modèle vers un écran réel + (ou à l'intérieur d'un conteneur d'un écran réel) : chaque élément copié + devient une vraie ligne indépendante en base, modifiable séparément par + la suite — exactement le comportement d'un élément de jeu "à l'ancienne" + posé sur un écran. Renvoie la liste des ids créés au premier niveau.""" + conn = db.connect(slug) + rows = conn.execute( + "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY id", (template_screen_id,) + ).fetchall() + conn.close() + source = [dict(r) for r in rows] + created_top_ids = [] + + def copy_one(src, new_parent_id): + conn = db.connect(slug) + max_z = conn.execute( + "SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (target_screen_id,) + ).fetchone()["m"] + conn.execute( + """INSERT INTO _screen_elements + (screen_id, parent_id, widget, name, content, attributes, x, y, width, height, z_index, style) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + target_screen_id, new_parent_id, src["widget"], src.get("name"), + src["content"], src["attributes"], src["x"], src["y"], src["width"], src["height"], + (max_z or 0) + 1, src["style"], + ), + ) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return new_id + + def walk(src_parent_id, new_parent_id): + for child in [s for s in source if s.get("parent_id") == src_parent_id]: + new_id = copy_one(child, new_parent_id) + walk(child["id"], new_id) + + for t in [s for s in source if not s.get("parent_id")]: + new_id = copy_one(t, target_parent_id) + created_top_ids.append(new_id) + walk(t["id"], new_id) + + return created_top_ids diff --git a/screens/element_types/list_element_types.py b/screens/element_types/list_element_types.py new file mode 100644 index 00000000..f2ddc769 --- /dev/null +++ b/screens/element_types/list_element_types.py @@ -0,0 +1,19 @@ +import json + +import db + +from ..screens_repo.ensure_schema import ensure_schema + + +def list_element_types(slug): + ensure_schema(slug) + conn = db.connect(slug) + rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + d["default_style"] = json.loads(d["default_style"] or "{}") + d["default_attributes"] = json.loads(d["default_attributes"] or "{}") + result.append(d) + return result diff --git a/screens/element_types/load_template_tree.py b/screens/element_types/load_template_tree.py new file mode 100644 index 00000000..630984a4 --- /dev/null +++ b/screens/element_types/load_template_tree.py @@ -0,0 +1,49 @@ +import json + +import db + + +def _load_template_tree(slug, element_type_id): + """Charge le contenu (éléments de premier niveau + descendants) de + l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier + quoi que ce soit en base) — utilisé quand un Répéteur de données affiche + cet élément de jeu comme modèle de ligne (voir _render_repeater) : on + relit et on rejoue son arbre à chaque ligne de données, avec les + {{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou + plus) d'écran-modèle valide.""" + conn = db.connect(slug) + et_row = conn.execute( + "SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,) + ).fetchone() + if not et_row or not et_row["template_screen_id"]: + conn.close() + return None + rows = conn.execute( + "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", + (et_row["template_screen_id"],), + ).fetchall() + conn.close() + elements = [] + for r in rows: + d = dict(r) + d["style"] = json.loads(d["style"] or "{}") + d["attributes"] = json.loads(d["attributes"] or "{}") + elements.append(d) + children_map = {} + for d in elements: + if d.get("parent_id"): + children_map.setdefault(d["parent_id"], []).append(d) + top = [d for d in elements if not d.get("parent_id")] + if not top: + return None + # Astuce de rendu : ces éléments sont "de premier niveau" dans leur + # écran-modèle (parent_id NULL en base, donc positionnés en absolu par + # défaut), mais une fois le modèle réutilisé ailleurs (ligne de + # Répéteur), ils doivent se comporter comme des enfants en flux normal, + # pas comme des boîtes positionnées en absolu sur un écran plein. On leur + # donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien + # que pour ce rendu éphémère — _style_string ne teste que sa présence, + # jamais sa valeur exacte, et cette copie n'est jamais enregistrée. + for t in top: + t["parent_id"] = t.get("parent_id") or "template-root" + return {"top": top, "children_map": children_map} diff --git a/screens/element_types/update_element_type.py b/screens/element_types/update_element_type.py new file mode 100644 index 00000000..7ac380a1 --- /dev/null +++ b/screens/element_types/update_element_type.py @@ -0,0 +1,13 @@ +import db + + +def update_element_type(slug, element_type_id, name=None, bound_definition_id=None): + conn = db.connect(slug) + if name: + conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id)) + conn.execute( + "UPDATE _element_types SET bound_definition_id = ? WHERE id = ?", + (int(bound_definition_id) if bound_definition_id else None, element_type_id), + ) + conn.commit() + conn.close() diff --git a/screens/elements/__init__.py b/screens/elements/__init__.py new file mode 100644 index 00000000..8f3407b2 --- /dev/null +++ b/screens/elements/__init__.py @@ -0,0 +1 @@ +"""Éléments posés sur un écran (_screen_elements).""" diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py new file mode 100644 index 00000000..ef7b3b0f --- /dev/null +++ b/screens/elements/add_element.py @@ -0,0 +1,84 @@ +import json + +import db + +from ..widgets.registry import WIDGETS +from ..widgets.widget_meta import widget_meta +from ..widgets.default_style_for_widget import default_style_and_attributes +from ..element_types.list_element_types import list_element_types +from ..element_types.instantiate_template_tree import instantiate_template_tree + + +def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None): + """parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur + de cet élément parent (qui doit être un conteneur/répéteur/groupe de + champs) plutôt que directement sur l'écran — sa position/taille ne sert + alors plus (il suit le flux du parent), mais on garde des valeurs par + défaut pour rester cohérent si jamais il en sort un jour. + + element_type_id : si fourni ET que cet élément de jeu a un écran-modèle + (le cas normal désormais — voir create_element_type), tout son arbre est + COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est + ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable + avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget + unique avec un contenu par défaut.""" + if element_type_id: + types = {t["id"]: t for t in list_element_types(slug)} + et = types.get(int(element_type_id)) + if et and et.get("template_screen_id"): + created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id) + if created: + conn = db.connect(slug) + conn.execute( + "UPDATE _screen_elements SET element_type_id = ? WHERE id = ?", (et["id"], created[0]) + ) + conn.commit() + conn.close() + return created[0] + # Modèle vide (aucun élément dedans pour l'instant) : on retombe + # sur l'ancien comportement plat ci-dessous, pour ne jamais + # échouer silencieusement à poser quelque chose. + + widget_key = widget_key if widget_key in WIDGETS else "conteneur" + meta = widget_meta(widget_key) + # Réglages dont la valeur par défaut a un effet visuel voulu dès la + # création (ex: une image démarre avec "cover" pour se recadrer + # proprement) — hors couleurs, qui restent volontairement transparentes/ + # sans bordure tant qu'on ne les choisit pas explicitement. + attributes, style = default_style_and_attributes(widget_key) + content = meta.get("default_content", "") + width, height = meta.get("w", 30), meta.get("h", 10) + + conn = db.connect(slug) + max_z = conn.execute( + "SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,) + ).fetchone()["m"] + # Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT + # SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le + # précédent et le recouvre entièrement — ce qui peut donner l'impression + # trompeuse qu'un "élément indésirable" vient d'apparaître et d'en + # cacher un autre (le plus souvent après une confusion entre "Ajouter un + # élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous + # les deux un nouvel élément mais pas au même endroit dans l'arbre). + x = y = 10 + if not parent_id: + top_level_count = conn.execute( + "SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL", + (screen_id,), + ).fetchone()["c"] + offset = (top_level_count % 8) * 4 + x = y = 10 + offset + conn.execute( + """INSERT INTO _screen_elements + (screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes), + x, y, width, height, (max_z or 0) + 1, + json.dumps(style), + ), + ) + element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return element_id diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py new file mode 100644 index 00000000..5aaaa178 --- /dev/null +++ b/screens/elements/delete_element.py @@ -0,0 +1,8 @@ +import db + + +def delete_element(slug, element_id): + conn = db.connect(slug) + conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,)) + conn.commit() + conn.close() diff --git a/screens/elements/element_label.py b/screens/elements/element_label.py new file mode 100644 index 00000000..523132b5 --- /dev/null +++ b/screens/elements/element_label.py @@ -0,0 +1,10 @@ +from ..widgets.registry import WIDGETS + + +def element_label(el, widgets_catalog=None): + """Nom lisible d'un élément pour les listes/menus : son nom personnalisé + s'il en a un, sinon le libellé de son widget (ex: "Image").""" + catalog = widgets_catalog if widgets_catalog is not None else WIDGETS + if el.get("name"): + return el["name"] + return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?")) diff --git a/screens/elements/get_element.py b/screens/elements/get_element.py new file mode 100644 index 00000000..03059904 --- /dev/null +++ b/screens/elements/get_element.py @@ -0,0 +1,15 @@ +import json + +import db + + +def get_element(slug, element_id): + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() + conn.close() + if not row: + return None + d = dict(row) + d["style"] = json.loads(d["style"] or "{}") + d["attributes"] = json.loads(d["attributes"] or "{}") + return d diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py new file mode 100644 index 00000000..11471e5c --- /dev/null +++ b/screens/elements/list_elements.py @@ -0,0 +1,34 @@ +import json + +import db + +from ..rendering.render_element_html import render_element_html + + +def list_elements(slug, screen_id): + """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.""" + conn = db.connect(slug) + rows = conn.execute( + "SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,) + ).fetchall() + conn.close() + result = [] + for r in rows: + d = dict(r) + d["style"] = json.loads(d["style"] or "{}") + d["attributes"] = json.loads(d["attributes"] or "{}") + result.append(d) + children_map = {} + for d in result: + if d.get("parent_id"): + children_map.setdefault(d["parent_id"], []).append(d) + for d in result: + if not d.get("parent_id"): + d["rendered_html"] = render_element_html(d, slug, children_map) + return result diff --git a/screens/elements/rename_element.py b/screens/elements/rename_element.py new file mode 100644 index 00000000..94026f46 --- /dev/null +++ b/screens/elements/rename_element.py @@ -0,0 +1,8 @@ +import db + + +def rename_element(slug, element_id, name): + conn = db.connect(slug) + conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id)) + conn.commit() + conn.close() diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py new file mode 100644 index 00000000..7df352b4 --- /dev/null +++ b/screens/elements/save_element_controls.py @@ -0,0 +1,113 @@ +import json + +import db + +from ..widgets.widget_meta import widget_meta +from .get_element import get_element + + +def _is_default_value(control, raw_value): + """Compare la valeur reçue du formulaire à la valeur par défaut du + réglage (en tenant compte de l'unité ajoutée pour les sliders), pour + savoir si le champ a simplement été renvoyé tel quel sans être + personnalisé.""" + default = control.get("default", "") + if control["type"] == "slider": + unit = control.get("unit", "") + default = f"{default}{unit}" if default not in (None, "") else "" + return str(raw_value) == str(default) + + +def save_element_controls(slug, element_id, form): + """Lit, pour chaque réglage du widget de cet élément, la valeur envoyée + par le panneau de propriétés (un seul formulaire, un champ par réglage) + et met à jour content / attributs / style en conséquence.""" + el = get_element(slug, element_id) + if not el: + return + meta = widget_meta(el["widget"]) + content = el["content"] + attributes = dict(el["attributes"]) + style = dict(el["style"]) + toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration) + + for control in meta.get("controls", []): + ctype = control["type"] + field_name = "ctrl_" + control["key"] + + if ctype == "toggle_token": + _kind, name = control["target"].split(":", 1) + toggle_groups.setdefault(name, set()) + if form.get(field_name) is not None: + toggle_groups[name].add(control["token"]) + continue + + if ctype == "preset": + chosen = form.get(field_name, control.get("default")) + opt = next((o for o in control["options"] if o[0] == chosen), None) + if opt: + for k, v in opt[2].items(): + if v in (None, ""): + style.pop(k, None) + else: + style[k] = v + continue + + if ctype == "scale": + try: + val = float(form.get(field_name, control.get("default", 1))) + except (TypeError, ValueError): + val = 1.0 + if abs(val - 1) < 1e-6: + style.pop("transform", None) + else: + style["transform"] = f"scale({val})" + continue + + target = control["target"] + if ctype == "checkbox": + checked = form.get(field_name) is not None + raw_value = control["true_value"] if checked else control["false_value"] + else: + raw_value = form.get(field_name, control.get("default", "")) + if ctype == "slider": + unit = control.get("unit", "") + raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else "" + + if target == "content": + content = raw_value + else: + kind, name = target.split(":", 1) + store = attributes if kind == "attr" else style + if raw_value in (None, ""): + store.pop(name, None) + elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value): + # Ce réglage n'a jamais été personnalisé (la clé est absente + # du style/des attributs stockés) et la valeur reçue est + # simplement celle par défaut affichée dans le champ (un + # ou un slider a TOUJOURS une valeur à + # l'écran, même quand rien n'a été choisi). Comme le + # panneau de propriétés envoie TOUS les réglages du widget + # en un seul formulaire, sauvegarder n'importe quel autre + # champ (ex. changer la disposition) écrirait sinon cette + # valeur par défaut "en dur" dans l'élément — ce qui faisait + # apparaître par exemple une couleur de fond qui n'avait + # jamais été demandée. On laisse donc ce réglage "non + # défini" tant qu'il n'a pas été explicitement changé. + pass + else: + store[name] = raw_value + + for prop, tokens in toggle_groups.items(): + if tokens: + style[prop] = " ".join(sorted(tokens)) + else: + style.pop(prop, None) + + conn = db.connect(slug) + conn.execute( + "UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?", + (content, json.dumps(attributes), json.dumps(style), element_id), + ) + conn.commit() + conn.close() diff --git a/screens/elements/update_element_geometry.py b/screens/elements/update_element_geometry.py new file mode 100644 index 00000000..f0155026 --- /dev/null +++ b/screens/elements/update_element_geometry.py @@ -0,0 +1,14 @@ +import db + + +def update_element_geometry(slug, element_id, x, y, width, height): + """Utilisé par le glisser-déposer / redimensionnement (et par les champs + Position & taille du panneau de droite) — ne touche qu'à la position et + à la taille, rien d'autre.""" + conn = db.connect(slug) + conn.execute( + "UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?", + (x, y, width, height, element_id), + ) + conn.commit() + conn.close() diff --git a/screens/flow/__init__.py b/screens/flow/__init__.py new file mode 100644 index 00000000..aaf92e8a --- /dev/null +++ b/screens/flow/__init__.py @@ -0,0 +1,2 @@ +"""Logique visuelle (éditeur de flow à nœuds) : déclencheurs, conditions, +actions, sous forme de graphe propre à chaque écran.""" diff --git a/screens/flow/add_flow_edge.py b/screens/flow/add_flow_edge.py new file mode 100644 index 00000000..5e8fc70c --- /dev/null +++ b/screens/flow/add_flow_edge.py @@ -0,0 +1,13 @@ +import db + + +def add_flow_edge(slug, screen_id, from_node_id, from_port, to_node_id): + conn = db.connect(slug) + conn.execute( + "INSERT INTO _flow_edges (screen_id, from_node_id, from_port, to_node_id) VALUES (?, ?, ?, ?)", + (screen_id, from_node_id, from_port or "out", to_node_id), + ) + edge_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return edge_id diff --git a/screens/flow/add_flow_node.py b/screens/flow/add_flow_node.py new file mode 100644 index 00000000..b03abd97 --- /dev/null +++ b/screens/flow/add_flow_node.py @@ -0,0 +1,21 @@ +import db + +from .ensure_flow_schema import ensure_flow_schema +from .constants import FLOW_NODE_FIELDS + + +def add_flow_node(slug, screen_id, node_type, x=40, y=40, **fields): + ensure_flow_schema(slug) + cols = ["screen_id", "node_type", "x", "y"] + vals = [screen_id, node_type, x, y] + for k, v in fields.items(): + if k in FLOW_NODE_FIELDS and v not in (None, ""): + cols.append(k) + vals.append(v) + placeholders = ", ".join("?" for _ in cols) + conn = db.connect(slug) + conn.execute(f"INSERT INTO _flow_nodes ({', '.join(cols)}) VALUES ({placeholders})", vals) + node_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return node_id diff --git a/screens/flow/constants.py b/screens/flow/constants.py new file mode 100644 index 00000000..84fe0536 --- /dev/null +++ b/screens/flow/constants.py @@ -0,0 +1,47 @@ +# ---------- Logique visuelle (éditeur de flow à nœuds) ---------- +# +# Remplace l'ancien système "Actions" attaché à chaque élément : toute la +# logique d'une scène (déclencheurs, conditions, actions) vit maintenant dans +# un graphe de nœuds propre à l'écran, visible dans le panneau "Logique de +# la scène" de l'éditeur (voir screen_edit.html) — un nœud Déclencheur +# ("Au clic sur X") relié à des nœuds Condition (deux sorties Vrai/Faux) et +# Action (les mêmes effets qu'avant : changer d'écran, modifier un élément, +# modifier une donnée). L'ancien système _actions reste en base pour ne rien +# casser sur les jeux déjà créés, mais n'est plus exposé dans l'éditeur ni +# utilisé par le mode jouable. + +TRIGGER_EVENTS = [ + ("clic", "Au clic"), + ("soumission", "À la soumission"), + # Pas de "trigger_element_id" pour celui-ci : il concerne l'ÉCRAN entier, + # pas un élément précis (voir 1.2 dans claude/forge-engine-lacunes-boitemail.md + # côté projet Forge — "affichage piloté par la donnée"). Se déclenche + # côté jouable à chaque fois que l'écran est montré (premier affichage, + # retour en arrière, changement d'écran...) ET après toute donnée + # modifiée pendant qu'on est déjà sur cet écran — ce qui permet de + # cacher/montrer un élément selon l'état de la partie SANS qu'un clic + # explicite soit nécessaire pour le réévaluer. + ("affichage", "À l'affichage de l'écran"), +] + +CONDITION_OPERATORS = [ + ("egal", "est égal à"), + ("different", "est différent de"), + ("superieur", "est supérieur à"), + ("inferieur", "est inférieur à"), + ("superieur_egal", "est supérieur ou égal à"), + ("inferieur_egal", "est inférieur ou égal à"), +] +CONDITION_OPERATOR_LABELS = dict(CONDITION_OPERATORS) + +FLOW_NODE_FIELDS = { + "trigger_element_id", "trigger_event", + "cond_definition_id", "cond_row_id", "cond_field", "cond_field_type", "cond_operator", "cond_value", + # 2.4 — conditions combinées (ET/OU) : cond_clauses est une liste JSON de + # clauses supplémentaires (en plus de la clause "historique" ci-dessus, + # qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir + # comment les combiner. Absents => comportement legacy (une seule clause). + "cond_clauses", "cond_combinator", + "action_type", "target_screen_id", "target_element_id", "element_property", "element_value", + "target_definition_id", "target_row_id", "target_field", "data_operation", "data_value", +} diff --git a/screens/flow/delete_flow_edge.py b/screens/flow/delete_flow_edge.py new file mode 100644 index 00000000..bf6f6226 --- /dev/null +++ b/screens/flow/delete_flow_edge.py @@ -0,0 +1,8 @@ +import db + + +def delete_flow_edge(slug, edge_id): + conn = db.connect(slug) + conn.execute("DELETE FROM _flow_edges WHERE id = ?", (edge_id,)) + conn.commit() + conn.close() diff --git a/screens/flow/delete_flow_node.py b/screens/flow/delete_flow_node.py new file mode 100644 index 00000000..f50cdafe --- /dev/null +++ b/screens/flow/delete_flow_node.py @@ -0,0 +1,9 @@ +import db + + +def delete_flow_node(slug, node_id): + conn = db.connect(slug) + conn.execute("DELETE FROM _flow_edges WHERE from_node_id = ? OR to_node_id = ?", (node_id, node_id)) + conn.execute("DELETE FROM _flow_nodes WHERE id = ?", (node_id,)) + conn.commit() + conn.close() diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py new file mode 100644 index 00000000..f0347d37 --- /dev/null +++ b/screens/flow/ensure_flow_schema.py @@ -0,0 +1,55 @@ +import db + + +def ensure_flow_schema(slug): + conn = db.connect(slug) + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS _flow_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + node_type TEXT NOT NULL, + x REAL NOT NULL DEFAULT 40, + y REAL NOT NULL DEFAULT 40, + trigger_element_id INTEGER REFERENCES _screen_elements(id), + trigger_event TEXT, + cond_definition_id INTEGER, + cond_row_id INTEGER, + cond_field TEXT, + cond_field_type TEXT, + cond_operator TEXT, + cond_value TEXT, + action_type TEXT, + target_screen_id INTEGER REFERENCES _screens(id), + target_element_id INTEGER REFERENCES _screen_elements(id), + element_property TEXT, + element_value TEXT, + target_definition_id INTEGER, + target_row_id INTEGER, + target_field TEXT, + data_operation TEXT, + data_value TEXT + ); + CREATE TABLE IF NOT EXISTS _flow_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + from_node_id INTEGER NOT NULL REFERENCES _flow_nodes(id) ON DELETE CASCADE, + from_port TEXT NOT NULL DEFAULT 'out', + to_node_id INTEGER NOT NULL REFERENCES _flow_nodes(id) ON DELETE CASCADE + ); + """ + ) + # 2.4 — conditions combinées (ET/OU) : un nœud Condition peut tester + # PLUSIEURS champs à la fois plutôt qu'un seul. cond_clauses (JSON, une + # liste de clauses en plus de la clause "historique" cond_definition_id/ + # cond_field/etc.) + cond_combinator ('et'/'ou') sont ajoutés en ALTER + # TABLE pour ne rien casser sur les jeux déjà créés — un nœud sans + # cond_clauses garde exactement son comportement d'avant (une seule + # comparaison), voir evaluateConditionNode() dans templates/play.html. + cols = {r["name"] for r in conn.execute("PRAGMA table_info(_flow_nodes)").fetchall()} + if "cond_clauses" not in cols: + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_clauses TEXT") + if "cond_combinator" not in cols: + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_combinator TEXT NOT NULL DEFAULT 'et'") + conn.commit() + conn.close() diff --git a/screens/flow/get_flow_node.py b/screens/flow/get_flow_node.py new file mode 100644 index 00000000..1058952c --- /dev/null +++ b/screens/flow/get_flow_node.py @@ -0,0 +1,8 @@ +import db + + +def get_flow_node(slug, node_id): + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _flow_nodes WHERE id = ?", (node_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/screens/flow/list_flow_edges.py b/screens/flow/list_flow_edges.py new file mode 100644 index 00000000..8f29b421 --- /dev/null +++ b/screens/flow/list_flow_edges.py @@ -0,0 +1,11 @@ +import db + +from .ensure_flow_schema import ensure_flow_schema + + +def list_flow_edges(slug, screen_id): + ensure_flow_schema(slug) + conn = db.connect(slug) + rows = conn.execute("SELECT * FROM _flow_edges WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/flow/list_flow_nodes.py b/screens/flow/list_flow_nodes.py new file mode 100644 index 00000000..8e0bcdd5 --- /dev/null +++ b/screens/flow/list_flow_nodes.py @@ -0,0 +1,11 @@ +import db + +from .ensure_flow_schema import ensure_flow_schema + + +def list_flow_nodes(slug, screen_id): + ensure_flow_schema(slug) + conn = db.connect(slug) + rows = conn.execute("SELECT * FROM _flow_nodes WHERE screen_id = ? ORDER BY id", (screen_id,)).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/flow/move_flow_node.py b/screens/flow/move_flow_node.py new file mode 100644 index 00000000..5b2d55fb --- /dev/null +++ b/screens/flow/move_flow_node.py @@ -0,0 +1,8 @@ +import db + + +def move_flow_node(slug, node_id, x, y): + conn = db.connect(slug) + conn.execute("UPDATE _flow_nodes SET x = ?, y = ? WHERE id = ?", (x, y, node_id)) + conn.commit() + conn.close() diff --git a/screens/labels/__init__.py b/screens/labels/__init__.py new file mode 100644 index 00000000..b7501d9d --- /dev/null +++ b/screens/labels/__init__.py @@ -0,0 +1,2 @@ +"""Petites constantes de libellés UI utilisées par le panneau de propriétés +et l'éditeur de flow.""" diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py new file mode 100644 index 00000000..28e02ae9 --- /dev/null +++ b/screens/labels/action_type_labels.py @@ -0,0 +1,11 @@ +ACTION_TYPE_LABELS = { + "ecran_suivant": "Afficher l'écran suivant", + "ecran_precedent": "Afficher l'écran précédent", + "aller_a": "Aller à un écran précis", + "modifier_element": "Modifier un élément", + "modifier_donnee": "Modifier une donnée d'un objet", + "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", + "activer_onglet": "Afficher cet élément, masquer tous ses frères", + "attendre": "Attendre quelques secondes avant de continuer", + "rien": "Ne rien faire", +} diff --git a/screens/labels/data_operations.py b/screens/labels/data_operations.py new file mode 100644 index 00000000..5159fd92 --- /dev/null +++ b/screens/labels/data_operations.py @@ -0,0 +1,12 @@ +# ---------- Actions : modifier une donnée d'objet ---------- + +DATA_OPERATIONS = [ + ("definir_texte", "Donner cette valeur — texte"), + ("definir_nombre", "Donner cette valeur — nombre"), + ("definir_bool_vrai", "Mettre à Vrai"), + ("definir_bool_faux", "Mettre à Faux"), + ("basculer_bool", "Basculer Vrai / Faux"), + ("incrementer", "Augmenter de..."), + ("decrementer", "Diminuer de..."), +] +DATA_OPERATION_LABELS = dict(DATA_OPERATIONS) diff --git a/screens/labels/element_action_properties.py b/screens/labels/element_action_properties.py new file mode 100644 index 00000000..0a479aa3 --- /dev/null +++ b/screens/labels/element_action_properties.py @@ -0,0 +1,38 @@ +# ---------- Actions : modifier un élément ---------- + +ELEMENT_ACTION_PROPERTIES = [ + ("visibilite", "Visibilité"), + ("bg_color", "Couleur de fond"), + ("border_color", "Couleur de bordure"), + ("width", "Largeur en %"), + ("height", "Hauteur en %"), + # 3.3 (Confort) — surbrillance générique dynamique : un liseré qui + # clignote autour de l'élément, pour attirer l'attention du joueur vers + # ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) — + # sans avoir à poser une bordure de couleur togglée à la main. Voir + # ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html. + ("surbrillance", "Surbrillance — liseré clignotant"), + # 3.5 (Confort) — verrouillage d'un élément après décision : grisé + + # ignore les clics, DISTINCT de "invisible" (qui garde sa place mais + # disparaît visuellement) — un bouton "Répondre" qui reste visible mais + # devient inerte une fois le mail traité, par exemple. Voir + # ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans + # play.html (pointer-events:none empêche aussi le déclencheur "Au clic" + # de se redéclencher tant que l'élément est désactivé). + ("desactive", "Désactivé — grisé, ignore les clics"), +] +ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES) + +ELEMENT_SURBRILLANCE_VALUES = [ + ("on", "Activer la surbrillance"), + ("off", "Désactiver la surbrillance"), + ("toggle", "Basculer activée / désactivée"), +] +ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES) + +ELEMENT_DESACTIVE_VALUES = [ + ("on", "Désactiver — griser, ignorer les clics"), + ("off", "Réactiver"), + ("toggle", "Basculer désactivé / réactivé"), +] +ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES) diff --git a/screens/labels/element_visibility.py b/screens/labels/element_visibility.py new file mode 100644 index 00000000..6817fb95 --- /dev/null +++ b/screens/labels/element_visibility.py @@ -0,0 +1,7 @@ +ELEMENT_VISIBILITY_VALUES = [ + ("visible", "Rendre visible"), + ("masque", "Masquer — n'occupe plus de place"), + ("invisible", "Rendre invisible — garde sa place"), + ("toggle", "Basculer visible / masqué"), +] +ELEMENT_VISIBILITY_LABELS = dict(ELEMENT_VISIBILITY_VALUES) diff --git a/screens/labels/trigger_labels.py b/screens/labels/trigger_labels.py new file mode 100644 index 00000000..c72a0e5d --- /dev/null +++ b/screens/labels/trigger_labels.py @@ -0,0 +1,5 @@ +TRIGGER_LABELS = { + "clic": "Au clic", + "soumission": "À la soumission", + "affichage": "À l'affichage de l'écran", +} diff --git a/screens/legacy_actions/__init__.py b/screens/legacy_actions/__init__.py new file mode 100644 index 00000000..c718b1f2 --- /dev/null +++ b/screens/legacy_actions/__init__.py @@ -0,0 +1,2 @@ +"""Ancien système d'actions par élément (_actions) — conservé pour ne rien +casser sur les jeux déjà créés, remplacé par le module flow/.""" diff --git a/screens/legacy_actions/add_action.py b/screens/legacy_actions/add_action.py new file mode 100644 index 00000000..dab0e4f3 --- /dev/null +++ b/screens/legacy_actions/add_action.py @@ -0,0 +1,25 @@ +import db + + +def add_action( + slug, element_id, trigger_event, action_type, target_screen_id=None, + target_element_id=None, element_property=None, element_value=None, + target_definition_id=None, target_row_id=None, target_field=None, + data_operation=None, data_value=None, +): + conn = db.connect(slug) + conn.execute( + """INSERT INTO _actions + (element_id, trigger_event, action_type, target_screen_id, + target_element_id, element_property, element_value, + target_definition_id, target_row_id, target_field, data_operation, data_value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + element_id, trigger_event, action_type, target_screen_id or None, + target_element_id or None, element_property or None, element_value or None, + target_definition_id or None, target_row_id or None, target_field or None, + data_operation or None, data_value or None, + ), + ) + conn.commit() + conn.close() diff --git a/screens/legacy_actions/delete_action.py b/screens/legacy_actions/delete_action.py new file mode 100644 index 00000000..39dc250c --- /dev/null +++ b/screens/legacy_actions/delete_action.py @@ -0,0 +1,8 @@ +import db + + +def delete_action(slug, action_id): + conn = db.connect(slug) + conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,)) + conn.commit() + conn.close() diff --git a/screens/legacy_actions/get_action.py b/screens/legacy_actions/get_action.py new file mode 100644 index 00000000..acb60484 --- /dev/null +++ b/screens/legacy_actions/get_action.py @@ -0,0 +1,8 @@ +import db + + +def get_action(slug, action_id): + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/screens/legacy_actions/list_actions.py b/screens/legacy_actions/list_actions.py new file mode 100644 index 00000000..ab9d8672 --- /dev/null +++ b/screens/legacy_actions/list_actions.py @@ -0,0 +1,8 @@ +import db + + +def list_actions(slug, element_id): + conn = db.connect(slug) + rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/payload/__init__.py b/screens/payload/__init__.py new file mode 100644 index 00000000..dde69c60 --- /dev/null +++ b/screens/payload/__init__.py @@ -0,0 +1 @@ +"""Sérialisation de tout le jeu pour le mode jouable.""" diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py new file mode 100644 index 00000000..5d90b790 --- /dev/null +++ b/screens/payload/full_game_payload.py @@ -0,0 +1,45 @@ +import db + +from ..screens_repo.list_screens import list_screens +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 + + +def full_game_payload(slug): + """Toutes les données nécessaires au runtime JS de la page de jeu jouable + (/game//play) : chaque écran avec ses éléments (HTML déjà généré, + y compris les Répéteurs de données, lus en direct), le graphe de logique + (nœuds + fils) de chaque écran, et un instantané des données du jeu + (avec le type de chaque champ, nécessaire pour évaluer une condition + correctement) — un seul aller-retour serveur, ensuite tout se joue côté + client (aucune navigation ne recharge la page, sauf pour appliquer une + action "modifier une donnée", qui doit passer par le serveur).""" + screens_ = list_screens(slug) + payload_screens = [] + flows = {} + animations = {} + for s in screens_: + elements = list_elements(slug, s["id"]) + payload_screens.append({**s, "elements": elements}) + flows[str(s["id"])] = { + "nodes": list_flow_nodes(slug, s["id"]), + "edges": list_flow_edges(slug, s["id"]), + } + animations[str(s["id"])] = list_animation_clips(slug, s["id"]) + + definitions = db.list_definitions(slug) + data = {} + fields_meta = {} + for d in definitions: + full = db.get_definition(slug, d["id"]) + rows = db.list_rows(slug, full) + cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]} + data[str(d["id"])] = [ + {**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]} + for r in rows + ] + 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} diff --git a/screens/rendering/__init__.py b/screens/rendering/__init__.py new file mode 100644 index 00000000..81766a1f --- /dev/null +++ b/screens/rendering/__init__.py @@ -0,0 +1 @@ +"""Génération du HTML réel des éléments posés sur un écran.""" diff --git a/screens/rendering/apply_ctx.py b/screens/rendering/apply_ctx.py new file mode 100644 index 00000000..8eb5e7fc --- /dev/null +++ b/screens/rendering/apply_ctx.py @@ -0,0 +1,9 @@ +def _apply_ctx(text, ctx): + """À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par + la valeur de ce champ pour la ligne en cours — appliqué au contenu ET + aux attributs de l'élément et de tous ses descendants.""" + if not ctx or not text: + return text + for k, v in ctx.items(): + text = text.replace("{{" + k + "}}", v) + return text diff --git a/screens/rendering/attr_string.py b/screens/rendering/attr_string.py new file mode 100644 index 00000000..5ef39d43 --- /dev/null +++ b/screens/rendering/attr_string.py @@ -0,0 +1,10 @@ +import html as html_lib + + +def _attr_string(attrs, extra_style): + parts = [f'style="{html_lib.escape(extra_style)}"'] + for k, v in attrs.items(): + if v in (None, "", False): + continue + parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"') + return " ".join(parts) diff --git a/screens/rendering/auto_id.py b/screens/rendering/auto_id.py new file mode 100644 index 00000000..334d2643 --- /dev/null +++ b/screens/rendering/auto_id.py @@ -0,0 +1,2 @@ +def _auto_id(el): + return f"elt-{el['id']}" diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py new file mode 100644 index 00000000..f3dfbaaf --- /dev/null +++ b/screens/rendering/filter_repeater_rows.py @@ -0,0 +1,94 @@ +import re + +import db + +# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour +# comparer le champ filtré à une valeur qui change en cours de partie (ex: +# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même +# convention de syntaxe que les {{champ}} déjà utilisés ailleurs (contenu +# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS). +_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$") + + +def _resolve_filter_value(slug, raw_value): + """Si raw_value est une référence "{{Objet.champ}}", va lire la valeur + ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par + convention, un objet utilisé comme "état de partie" — niveau courant, + outils débloqués... — ne garde qu'une seule ligne, mise à jour en place + par des actions "Modifier une donnée" plutôt que d'en créer une + nouvelle à chaque fois). Sinon, renvoie raw_value tel quel (valeur + fixe).""" + if not raw_value: + return raw_value + m = _REF_PATTERN.match(raw_value.strip()) + if not m: + return raw_value + object_name, field_name = m.group(1).strip(), m.group(2).strip() + for d in db.list_definitions(slug): + if d["name"] == object_name: + full = db.get_definition(slug, d["id"]) + rows = db.list_rows(slug, full) + if rows: + return rows[0].get(db.slugify(field_name).replace("-", "_")) + return None + return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter + + +def _compare(actual, operator, expected, field_type): + """Équivalent Python de compareValues() dans templates/play.html — les + deux doivent rester alignées si on ajoute un opérateur.""" + if field_type == "booleen": + a = 1 if actual else 0 + e = 1 if str(expected).strip().lower() in ("1", "true", "vrai") else 0 + return a != e if operator == "different" else a == e + try: + an, en = float(actual), float(expected) + numeric = True + except (TypeError, ValueError): + numeric = False + if numeric: + if operator == "egal": + return an == en + if operator == "different": + return an != en + if operator == "superieur": + return an > en + if operator == "inferieur": + return an < en + if operator == "superieur_egal": + return an >= en + if operator == "inferieur_egal": + return an <= en + return False + as_, es_ = ("" if actual is None else str(actual)), ("" if expected is None else str(expected)) + if operator == "egal": + return as_ == es_ + if operator == "different": + return as_ != es_ + if operator == "superieur": + return as_ > es_ + if operator == "inferieur": + return as_ < es_ + if operator == "superieur_egal": + return as_ >= es_ + if operator == "inferieur_egal": + return as_ <= es_ + return False + + +def filter_repeater_rows(slug, definition, rows, attrs): + """Ne garde, parmi les lignes d'un Répéteur, que celles qui correspondent + au filtre optionnel réglé dans ses propriétés (_filtre_champ / + _filtre_operateur / _filtre_valeur) — voir 1.2 dans la doc de cadrage + Forge Engine ("affichage piloté par la donnée sur un écran unique") : + c'est ce qui permet à un seul écran de n'afficher, par exemple, que les + mails du niveau en cours plutôt que la boîte de réception entière.""" + field_name = (attrs.get("_filtre_champ") or "").strip() + operator = attrs.get("_filtre_operateur") or "egal" + if not field_name: + return rows + raw_value = attrs.get("_filtre_valeur") or "" + expected = _resolve_filter_value(slug, raw_value) + field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte") + col = db.slugify(field_name).replace("-", "_") + return [r for r in rows if _compare(r.get(col), operator, expected, field_type)] diff --git a/screens/rendering/parent_flex_direction.py b/screens/rendering/parent_flex_direction.py new file mode 100644 index 00000000..c5ac0967 --- /dev/null +++ b/screens/rendering/parent_flex_direction.py @@ -0,0 +1,9 @@ +def _parent_flex_direction(parent_el): + """Sens de la disposition interne d'un conteneur/répéteur/groupe de + champs (voir le réglage "Disposition interne"), ou None s'il est en + empilement libre (pas de display:flex) — utilisé pour adapter la taille + par défaut de ses enfants (voir _style_string).""" + style = (parent_el or {}).get("style") or {} + if style.get("display") != "flex": + return None + return style.get("flex-direction") or "row" diff --git a/screens/rendering/render_checkbox_or_radio.py b/screens/rendering/render_checkbox_or_radio.py new file mode 100644 index 00000000..63b8eb04 --- /dev/null +++ b/screens/rendering/render_checkbox_or_radio.py @@ -0,0 +1,15 @@ +import html as html_lib + +from .visible_attrs import _visible_attrs +from .apply_ctx import _apply_ctx +from .style_string import _style_string +from .attr_string import _attr_string + + +def _render_checkbox_or_radio(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None): + input_type = "checkbox" if el.get("widget") == "case_a_cocher" else "radio" + attrs = _visible_attrs(el, meta, ctx) + attrs["type"] = input_type + style = _style_string(el, " display:flex; align-items:center; gap:8px;", parent_flex_direction) + content = _apply_ctx(el.get("content") or "", ctx) + return f'' diff --git a/screens/rendering/render_children.py b/screens/rendering/render_children.py new file mode 100644 index 00000000..020d6613 --- /dev/null +++ b/screens/rendering/render_children.py @@ -0,0 +1,13 @@ +from .parent_flex_direction import _parent_flex_direction + + +def _render_children(el, slug, children_map, ctx): + """Rendu récursif des éléments physiquement posés à l'intérieur d'un + conteneur/répéteur/groupe de champs — chacun avec sa propre disposition, + et le même contexte de ligne (ctx) que son parent s'il est dans un + répéteur de données.""" + from .render_element_html import render_element_html + + kids = (children_map or {}).get(el["id"], []) + flex_dir = _parent_flex_direction(el) + return "".join(render_element_html(c, slug, children_map, ctx, parent_flex_direction=flex_dir) for c in kids) diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py new file mode 100644 index 00000000..bbed65eb --- /dev/null +++ b/screens/rendering/render_element_html.py @@ -0,0 +1,76 @@ +import html as html_lib + +from ..widgets.widget_meta import widget_meta +from .visible_attrs import _visible_attrs +from .style_string import _style_string +from .attr_string import _attr_string +from .apply_ctx import _apply_ctx +from .render_children import _render_children +from .render_table import _render_table +from .render_select import _render_select +from .render_fieldset import _render_fieldset +from .render_checkbox_or_radio import _render_checkbox_or_radio +from .render_repeater import _render_repeater +from .render_jauge import _render_jauge +from .render_overlay import _render_overlay + + +def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None): + """Construit le HTML réel (balise + attributs + style + contenu, y + compris les éléments physiquement posés à l'intérieur pour un + conteneur/répéteur/groupe de champs) — utilisé par l'éditeur (canevas) + et le mode jouable. N'écrit jamais rien sur disque : uniquement du HTML + généré à la volée à partir de ce qui est stocké en base. `slug` est + nécessaire pour le Répéteur de données (il doit lire les données du jeu + au moment de l'affichage). `ctx` porte les valeurs {{champ}} de la ligne + en cours quand cet élément est descendant d'un répéteur. `parent_flex_direction` + est la disposition interne (voir _parent_flex_direction) du conteneur qui + pose physiquement cet élément, s'il y en a un — elle influence la largeur + par défaut de cet élément (voir _style_string).""" + meta = widget_meta(el.get("widget")) + special = meta.get("special_render") + if special == "table": + return _render_table(el, meta, slug, children_map, ctx, parent_flex_direction) + if special == "select_options": + return _render_select(el, meta, slug, children_map, ctx, parent_flex_direction) + if special == "fieldset_legend": + return _render_fieldset(el, meta, slug, children_map, ctx, parent_flex_direction) + if special == "checkbox_label": + return _render_checkbox_or_radio(el, meta, slug, children_map, ctx, parent_flex_direction) + if special == "repeater": + return _render_repeater(el, meta, slug, children_map, ctx, parent_flex_direction) + if special == "jauge": + return _render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction) + if special == "overlay": + return _render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction) + + tag = meta.get("tag") + if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3) + niveau = (el.get("attributes") or {}).get("_niveau", "2") + tag = f"h{niveau}" + + attrs = _visible_attrs(el, meta, ctx) + style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget")) + + # 3.1 (Confort) — interactions au survol : un texte de remplacement + # optionnel, affiché à la place du contenu normal quand le joueur + # survole cet élément à la souris (voir bindHoverTexts() dans + # templates/play.html). Absent par défaut => aucun changement, donc + # aucune régression sur les éléments déjà créés. + hover_text = (el.get("attributes") or {}).get("_survol_texte") + if hover_text: + attrs["data-hover-text"] = _apply_ctx(hover_text, ctx) + + if meta.get("self_closing"): + return f"<{tag} {_attr_string(attrs, style)}>" + + content = _apply_ctx(el.get("content") or "", ctx) + child_html = _render_children(el, slug, children_map, ctx) + + if tag in ("ul", "ol"): + items = [line.strip() for line in content.split("\n") if line.strip()] + inner = "".join(f"
  • {html_lib.escape(item)}
  • " for item in items) + child_html + return f"<{tag} {_attr_string(attrs, style)}>{inner}" + + safe_content = html_lib.escape(content).replace("\n", "
    ") if content else "" + return f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}" diff --git a/screens/rendering/render_fieldset.py b/screens/rendering/render_fieldset.py new file mode 100644 index 00000000..d897c66d --- /dev/null +++ b/screens/rendering/render_fieldset.py @@ -0,0 +1,15 @@ +import html as html_lib + +from .visible_attrs import _visible_attrs +from .apply_ctx import _apply_ctx +from .style_string import _style_string +from .attr_string import _attr_string +from .render_children import _render_children + + +def _render_fieldset(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None): + attrs = _visible_attrs(el, meta, ctx) + style = _style_string(el, parent_flex_direction=parent_flex_direction, widget_key=el.get("widget")) + content = _apply_ctx(el.get("content") or "", ctx) or "Groupe de champs" + child_html = _render_children(el, slug, children_map, ctx) + return f'
    {html_lib.escape(content)}{child_html}
    ' diff --git a/screens/rendering/render_jauge.py b/screens/rendering/render_jauge.py new file mode 100644 index 00000000..e9ea3c12 --- /dev/null +++ b/screens/rendering/render_jauge.py @@ -0,0 +1,97 @@ +import html as html_lib + +import db + +from .style_string import _style_string +from .auto_id import _auto_id + + +def _hex_to_rgb(hex_color): + hex_color = (hex_color or "#5b8cff").lstrip("#") + if len(hex_color) != 6: + hex_color = "5b8cff" + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + + +def _interpolate_color(low_hex, high_hex, ratio): + """Dégradé linéaire simple entre deux couleurs (rouge en bas, vert en + haut par défaut, comme demandé dans le document d'interface de Boîte + Mail) — ratio est déjà borné entre 0 et 1 par l'appelant.""" + lo, hi = _hex_to_rgb(low_hex), _hex_to_rgb(high_hex) + mixed = tuple(round(lo[i] + (hi[i] - lo[i]) * ratio) for i in range(3)) + return "#{:02x}{:02x}{:02x}".format(*mixed) + + +def _read_latest_field_value(slug, definition_id, field_name): + """Lit la valeur du champ demandé sur la ligne la PLUS RÉCENTE de + l'objet visé — même convention que le filtre de Répéteur (1.2) : un + objet utilisé comme jauge de partie (Réputation, Trésorerie...) ne + garde qu'une seule ligne, mise à jour en place.""" + if not definition_id or not slug: + return None + definition = db.get_definition(slug, int(definition_id)) + if not definition: + return None + rows = db.list_rows(slug, definition) + if not rows: + return None + col = db.slugify(field_name).replace("-", "_") + return rows[0].get(col) + + +def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None): + """Jauge/barre dont le remplissage (largeur en %) et la couleur (dégradé + bas -> haut) se recalculent automatiquement à partir d'un champ + numérique d'un objet de données — voir 2.1 dans la doc de cadrage Forge + Engine ("binding live d'une donnée vers un style"). Recalculée à chaque + régénération du HTML du jeu (chargement de /play, et après toute action + "Modifier une donnée"), donc toujours à jour sans action supplémentaire + à poser dans le graphe de logique.""" + attrs = el.get("attributes") or {} + style = _style_string(el, " overflow:hidden; position:relative; background:#1c2130; border-radius:6px;", parent_flex_direction) + definition_id = attrs.get("_definition_id") + field_name = (attrs.get("_champ") or "").strip() + try: + min_v = float(attrs.get("_min", "0") or 0) + except (TypeError, ValueError): + min_v = 0.0 + try: + max_v = float(attrs.get("_max", "100") or 100) + except (TypeError, ValueError): + max_v = 100.0 + couleur_basse = attrs.get("_couleur_basse") or "#e05252" + couleur_haute = attrs.get("_couleur_haute") or "#4caf50" + afficher_valeur = attrs.get("_afficher_valeur") == "1" + + raw_value = _read_latest_field_value(slug, definition_id, field_name) if field_name else None + try: + value = float(raw_value) + except (TypeError, ValueError): + value = min_v + + span = (max_v - min_v) or 1 + ratio = max(0.0, min(1.0, (value - min_v) / span)) + percent = round(ratio * 100, 1) + fill_color = _interpolate_color(couleur_basse, couleur_haute, ratio) + + label_html = "" + if afficher_valeur: + label_html = ( + f'
    {html_lib.escape(str(raw_value if raw_value is not None else "—"))}
    ' + ) + + # La classe "jaugeBar" est repérée côté client (play.html, refreshRuntimeData) + # pour savoir que ce wrapper doit être régénéré après toute action + # "Modifier une donnée" — sinon la jauge resterait figée sur sa valeur + # de départ après un changement, exactement comme il fallait déjà + # repérer "repeaterItem" pour les Répéteurs (voir 1.1/1.2). + bar = ( + f'
    ' + ) + return ( + f'
    ' + f'{bar}{label_html}
    ' + ) diff --git a/screens/rendering/render_overlay.py b/screens/rendering/render_overlay.py new file mode 100644 index 00000000..1ea33d3b --- /dev/null +++ b/screens/rendering/render_overlay.py @@ -0,0 +1,54 @@ +import html as html_lib + +from .visible_attrs import _visible_attrs +from .style_string import _style_string +from .render_children import _render_children + + +def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None): + """3.4 (Confort) — overlay/modale réutilisable : une boîte de dialogue + prête à l'emploi, par-dessus TOUT le reste de l'écran, fermeture + manuelle uniquement (aucun clic-en-dehors-pour-fermer volontairement, + conformément au manque documenté). Contrairement aux autres widgets, sa + position ne dépend PAS de x/y/width/height (glissé-déposé sur le + canevas) : `position:fixed; inset:0` la fait toujours couvrir tout + l'écran, quel que soit l'endroit où elle a été posée dans l'éditeur — + seul un voile semi-transparent + une boîte centrée, contenant les + éléments posés à l'intérieur (comme un conteneur normal). + + Ouverture/fermeture : PAS de mécanisme dédié — elle réutilise l'action + existante "Modifier un élément → Visibilité" (masquer/rendre visible), + exactement comme n'importe quel autre élément. C'est pour ça qu'on + fusionne le style de positionnement fixe ci-dessous AVEC le style normal + de l'élément (_style_string) : si l'élément est réglé "Masqué" dans ses + propriétés, ou si une action "Modifier un élément" le cache plus tard, + ce masquage continue de fonctionner normalement.""" + attrs_raw = el.get("attributes") or {} + box_color = attrs_raw.get("_couleur_boite") or "#1f2430" + radius = attrs_raw.get("_arrondi") or "12" + + attrs = _visible_attrs(el, meta, ctx) + base_overlay_style = ( + "position:fixed; inset:0; z-index:9999; display:flex; " + "align-items:center; justify-content:center; background:rgba(0,0,0,0.6);" + ) + style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction) + + attr_parts = [f'style="{html_lib.escape(style)}"'] + for k, v in attrs.items(): + if v in (None, "", False): + continue + attr_parts.append( + html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"' + ) + + box_style = ( + f"background:{html_lib.escape(str(box_color))}; border-radius:{html_lib.escape(str(radius))}px; " + "padding:24px; max-width:90%; max-height:90%; overflow:auto; box-sizing:border-box;" + ) + child_html = _render_children(el, slug, children_map, ctx) + return ( + f'
    ' + f'
    {child_html}
    ' + f"
    " + ) diff --git a/screens/rendering/render_repeater.py b/screens/rendering/render_repeater.py new file mode 100644 index 00000000..fcfda19f --- /dev/null +++ b/screens/rendering/render_repeater.py @@ -0,0 +1,71 @@ +import html as html_lib + +import db + +from .style_string import _style_string +from .auto_id import _auto_id +from .apply_ctx import _apply_ctx +from .filter_repeater_rows import filter_repeater_rows +from ..element_types.load_template_tree import _load_template_tree + + +def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_direction=None): + from .render_element_html import render_element_html + + style = _style_string(el, " overflow:auto;", parent_flex_direction, widget_key=el.get("widget")) + attrs_raw = el.get("attributes") or {} + definition_id = attrs_raw.get("_definition_id") + # data-definition-id sur le Répéteur lui-même : lu côté client (play.html) + # pour savoir, au clic sur une de ses lignes, à quel objet de données + # appartient la ligne cliquée (voir déclencheur "clic sur une ligne de + # Répéteur" et l'action "Ouvrir la ligne cliquée"). + definition_attr = f' data-definition-id="{html_lib.escape(str(definition_id))}"' if definition_id else "" + template_type_id = attrs_raw.get("_template_type_id") + template = _apply_ctx(el.get("content") or "", ctx) + children = (children_map or {}).get(el["id"], []) + if not definition_id or not slug: + return f'
    Répéteur : choisis un objet de données dans les propriétés.
    ' + definition = db.get_definition(slug, int(definition_id)) + if not definition: + return f'
    Objet introuvable — a-t-il été supprimé ?
    ' + # (definition_attr est ajouté au conteneur du Répéteur plus bas, une fois + # qu'on sait que l'objet est valide.) + rows = db.list_rows(slug, definition) + rows = filter_repeater_rows(slug, definition, rows, attrs_raw) + field_cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in definition["fields"]} + + # Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) : + # on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié + # en base), et on l'affiche avec les {{champ}} de cette ligne — voir + # _load_template_tree pour le détail de cette astuce de rendu. + template_tree = None + if template_type_id: + try: + template_tree = _load_template_tree(slug, int(template_type_id)) + except (TypeError, ValueError): + template_tree = None + + items = [] + for row in rows: + row_ctx = dict(ctx or {}) + row_ctx.update({fname: str(row.get(col, "") or "") for fname, col in field_cols.items()}) + if template_tree: + inner = "".join( + render_element_html(c, slug, template_tree["children_map"], row_ctx) + for c in template_tree["top"] + ) + elif children: + inner = "".join(render_element_html(c, slug, children_map, row_ctx) for c in children) + else: + line = template + for fname, val in row_ctx.items(): + line = line.replace("{{" + fname + "}}", val) + inner = html_lib.escape(line) + # data-row-id : identifiant STABLE de la vraie ligne de données + # affichée (par opposition à data-element-id, qui vaut l'id du + # modèle de ligne et se répète à l'identique sur chaque ligne) — + # c'est ce qui permet au clic de savoir "quel mail" a été ouvert. + items.append(f'
    {inner}
    ') + empty_msg = "Aucune ligne ne correspond au filtre réglé." if attrs_raw.get("_filtre_champ") else "Aucune donnée dans cet objet pour l'instant." + inner_all = "".join(items) if items else f"{empty_msg}" + return f'
    {inner_all}
    ' diff --git a/screens/rendering/render_select.py b/screens/rendering/render_select.py new file mode 100644 index 00000000..d46eafc7 --- /dev/null +++ b/screens/rendering/render_select.py @@ -0,0 +1,15 @@ +import html as html_lib + +from .visible_attrs import _visible_attrs +from .apply_ctx import _apply_ctx +from .style_string import _style_string +from .attr_string import _attr_string + + +def _render_select(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None): + attrs = _visible_attrs(el, meta, ctx) + style = _style_string(el, parent_flex_direction=parent_flex_direction) + content = _apply_ctx(el.get("content") or "", ctx) + options = [o.strip() for o in content.split("\n") if o.strip()] + opts_html = "".join(f'' for o in options) + return f'' diff --git a/screens/rendering/render_table.py b/screens/rendering/render_table.py new file mode 100644 index 00000000..689f26b7 --- /dev/null +++ b/screens/rendering/render_table.py @@ -0,0 +1,24 @@ +import html as html_lib + +from .visible_attrs import _visible_attrs +from .apply_ctx import _apply_ctx +from .style_string import _style_string +from .attr_string import _attr_string + + +def _render_table(el, meta, slug=None, children_map=None, ctx=None, parent_flex_direction=None): + attrs = _visible_attrs(el, meta, ctx) + header_row = (el.get("attributes") or {}).get("_header_row") in ("1", "true", "on") + content = _apply_ctx(el.get("content") or "", ctx) + style = _style_string(el, " border-collapse:collapse;", parent_flex_direction) + rows = [r for r in content.split("\n") if r.strip()] + html_rows = [] + for i, r in enumerate(rows): + cells = r.split(";") + cell_tag = "th" if (header_row and i == 0) else "td" + cells_html = "".join( + f'<{cell_tag} style="border:1px solid #2b3140; padding:6px 8px;">{html_lib.escape(c.strip())}' + for c in cells + ) + html_rows.append(f"{cells_html}") + return f'{"".join(html_rows)}
    ' diff --git a/screens/rendering/style_string.py b/screens/rendering/style_string.py new file mode 100644 index 00000000..df479aa8 --- /dev/null +++ b/screens/rendering/style_string.py @@ -0,0 +1,70 @@ +from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS + + +def _style_string(el, extra_base="", parent_flex_direction=None, widget_key=None): + style = dict(el.get("style") or {}) + if widget_key in LAYOUT_CAPABLE_WIDGETS and "display" not in style: + # "Empilement libre" (valeur par défaut de "Disposition interne") + # doit quand même permettre l'espacement (gap) entre les enfants — + # or le CSS "gap" n'a aucun effet sur un display bloc classique, il + # lui faut un conteneur flex/grid. Tant qu'aucune disposition n'a + # été choisie explicitement, on bascule donc ce widget sur un flex + # en colonne : visuellement identique à un simple empilement, mais + # l'espacement fonctionne dès la création, sans avoir à changer la + # disposition interne au préalable. + style["display"] = "flex" + style.setdefault("flex-direction", "column") + is_child = bool(el.get("parent_id")) + if is_child: + # Élément physiquement posé à l'intérieur d'un conteneur/répéteur/ + # groupe de champs : une hauteur fixée à 100% se calcule contre la + # hauteur du parent et écraserait/déborderait les autres enfants + # (le conteneur coupe alors ce qui dépasse -> l'enfant "disparaît"). + # La hauteur s'adapte donc à son contenu, comme dans une vraie page. + # Largeur : dans une disposition en LIGNE (flex-direction row), la + # largeur est l'axe principal — si chaque enfant réclame 100% de + # cette largeur, ils entrent en collision (l'algorithme flex les + # comprime alors tous, jusqu'à les faire quasi disparaître). Dans les + # autres cas (empilement libre, ou disposition en COLONNE où la + # largeur est l'axe transversal), 100% reste le bon défaut. + width_default = "auto" if parent_flex_direction in ("row", "row-reverse") else "100%" + base_style = f"width:{width_default}; height:auto; box-sizing:border-box; margin:0;" + extra_base + else: + base_style = "width:100%; height:100%; box-sizing:border-box; margin:0;" + extra_base + + has_size_override = False + + def _emit(k, v): + nonlocal has_size_override + if v in (None, ""): + return False + if k == "transform" and not is_child: + # Un élément posé directement sur un écran a déjà ce "transform" + # posé sur son cadre .canvasElementInner / .playElementInner + # (voir filters/element_style_filter.py:_element_transform_style) + # — l'écrire ici aussi (sur la balise intérieure, à l'intérieur + # d'un cadre qui reste fixe) donnait l'impression que l'échelle/ + # l'animation restait "coincée à l'intérieur" au lieu de + # s'appliquer à l'élément entier. Un élément posé à l'intérieur + # d'un conteneur/répéteur/groupe de champs n'a pas ce cadre + # séparé (sa balise EST déjà tout l'élément) : pour lui, + # "transform" reste ici. + return False + if k in ("width", "height") and str(v).strip() in ("0px", "0"): + # 0 = réglage "Taille dans le conteneur" laissé à sa valeur par + # défaut (voir SIZE_OVERRIDE_CONTROLS) : ne pas l'écrire, pour + # garder le comportement automatique de base_style ci-dessus. + return False + if k in ("width", "height"): + has_size_override = True + return True + + user_style = "; ".join(f"{k}:{v}" for k, v in style.items() if _emit(k, v)) + if has_size_override: + # Une taille fixe explicite (voir "Taille dans le conteneur") ne doit + # pas être écrasée par la compression automatique d'une disposition + # flex (flex-shrink vaut 1 par défaut sur tout enfant) — sinon un + # élément à qui on a donné une largeur/hauteur précise peut quand + # même se retrouver comprimé si ses voisins manquent de place. + user_style = (user_style + "; " if user_style else "") + "flex-shrink:0" + return base_style + (" " + user_style + ";" if user_style else "") diff --git a/screens/rendering/trigger_for.py b/screens/rendering/trigger_for.py new file mode 100644 index 00000000..9fd0f6ec --- /dev/null +++ b/screens/rendering/trigger_for.py @@ -0,0 +1,4 @@ +def _trigger_for(widget_key, attributes): + if (attributes or {}).get("type") == "submit": + return "soumission" + return "clic" diff --git a/screens/rendering/visible_attrs.py b/screens/rendering/visible_attrs.py new file mode 100644 index 00000000..4cd96370 --- /dev/null +++ b/screens/rendering/visible_attrs.py @@ -0,0 +1,22 @@ +from .apply_ctx import _apply_ctx +from .auto_id import _auto_id + + +def _visible_attrs(el, meta, ctx=None): + attrs = { + k: (_apply_ctx(v, ctx) if isinstance(v, str) else v) + for k, v in (el.get("attributes") or {}).items() if not k.startswith("_") + } + attrs.update(meta.get("fixed_attrs", {})) + attrs.setdefault("id", _auto_id(el)) + attrs["data-element-id"] = el["id"] + if el.get("parent_id"): + # Élément posé à l'intérieur d'un conteneur/répéteur/groupe de + # champs : pas de cadre de positionnement séparé autour de lui (voir + # element_style_filter.py) — sa propre balise EST déjà tout + # l'élément, donc c'est elle que la timeline d'animation doit viser. + # Un élément posé directement sur un écran, lui, porte ce marqueur + # sur son cadre (.canvasElement / .playElement) à la place — voir + # les gabarits screen_edit.html et play.html. + attrs["data-anim-target"] = el["id"] + return attrs diff --git a/screens/screens_repo/__init__.py b/screens/screens_repo/__init__.py new file mode 100644 index 00000000..c1a59d40 --- /dev/null +++ b/screens/screens_repo/__init__.py @@ -0,0 +1 @@ +"""Accès aux écrans du jeu (_screens) et au schéma de base commun au module.""" diff --git a/screens/screens_repo/create_screen.py b/screens/screens_repo/create_screen.py new file mode 100644 index 00000000..12ca8317 --- /dev/null +++ b/screens/screens_repo/create_screen.py @@ -0,0 +1,21 @@ +import db + +from .ensure_schema import ensure_schema + + +def create_screen(slug, name, is_template=False): + ensure_schema(slug) + conn = db.connect(slug) + if is_template: + order_index = 0 + else: + max_order = conn.execute("SELECT MAX(order_index) AS m FROM _screens WHERE is_template = 0").fetchone()["m"] + order_index = (max_order or 0) + 1 + conn.execute( + "INSERT INTO _screens (name, order_index, is_template) VALUES (?, ?, ?)", + (name, order_index, 1 if is_template else 0), + ) + screen_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return screen_id diff --git a/screens/screens_repo/delete_screen.py b/screens/screens_repo/delete_screen.py new file mode 100644 index 00000000..7f161ac4 --- /dev/null +++ b/screens/screens_repo/delete_screen.py @@ -0,0 +1,8 @@ +import db + + +def delete_screen(slug, screen_id): + conn = db.connect(slug) + conn.execute("DELETE FROM _screens WHERE id = ?", (screen_id,)) + conn.commit() + conn.close() diff --git a/screens/screens_repo/ensure_schema.py b/screens/screens_repo/ensure_schema.py new file mode 100644 index 00000000..49dff2bb --- /dev/null +++ b/screens/screens_repo/ensure_schema.py @@ -0,0 +1,124 @@ +import db + + +def ensure_schema(slug): + """Crée les tables de ce module si elles n'existent pas encore — les + jeux créés avant l'ajout de cette fonctionnalité n'ont pas besoin d'être + migrés manuellement, la première visite d'un écran de ce jeu suffit.""" + conn = db.connect(slug) + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS _screens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + is_template INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS _element_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + icon TEXT NOT NULL DEFAULT '🧩', + default_widget TEXT NOT NULL DEFAULT 'conteneur', + default_content TEXT NOT NULL DEFAULT '', + default_attributes TEXT NOT NULL DEFAULT '{}', + default_style TEXT NOT NULL DEFAULT '{}', + template_screen_id INTEGER REFERENCES _screens(id), + bound_definition_id INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS _screen_elements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES _screen_elements(id) ON DELETE CASCADE, + widget TEXT NOT NULL DEFAULT 'conteneur', + element_type_id INTEGER REFERENCES _element_types(id), + name TEXT, + content TEXT NOT NULL DEFAULT '', + attributes TEXT NOT NULL DEFAULT '{}', + x REAL NOT NULL DEFAULT 10, + y REAL NOT NULL DEFAULT 10, + width REAL NOT NULL DEFAULT 20, + height REAL NOT NULL DEFAULT 10, + z_index INTEGER NOT NULL DEFAULT 1, + style TEXT NOT NULL DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS _actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + element_id INTEGER NOT NULL REFERENCES _screen_elements(id) ON DELETE CASCADE, + trigger_event TEXT NOT NULL, + action_type TEXT NOT NULL, + target_screen_id INTEGER REFERENCES _screens(id), + target_element_id INTEGER REFERENCES _screen_elements(id), + element_property TEXT, + element_value TEXT, + target_definition_id INTEGER, + target_row_id INTEGER, + target_field TEXT, + data_operation TEXT, + data_value TEXT + ); + """ + ) + cols = {r["name"] for r in conn.execute("PRAGMA table_info(_screen_elements)").fetchall()} + if "widget" not in cols: + conn.execute("ALTER TABLE _screen_elements ADD COLUMN widget TEXT NOT NULL DEFAULT 'conteneur'") + if "attributes" not in cols: + conn.execute("ALTER TABLE _screen_elements ADD COLUMN attributes TEXT NOT NULL DEFAULT '{}'") + if "parent_id" not in cols: + conn.execute("ALTER TABLE _screen_elements ADD COLUMN parent_id INTEGER REFERENCES _screen_elements(id) ON DELETE CASCADE") + if "name" not in cols: + conn.execute("ALTER TABLE _screen_elements ADD COLUMN name TEXT") + type_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_element_types)").fetchall()} + if "default_widget" not in type_cols: + conn.execute("ALTER TABLE _element_types ADD COLUMN default_widget TEXT NOT NULL DEFAULT 'conteneur'") + conn.execute("ALTER TABLE _element_types ADD COLUMN default_content TEXT NOT NULL DEFAULT ''") + conn.execute("ALTER TABLE _element_types ADD COLUMN default_attributes TEXT NOT NULL DEFAULT '{}'") + if "template_screen_id" not in type_cols: + # Un élément de jeu n'est plus un simple widget isolé : c'est un + # conteneur avec un vrai arbre d'éléments imbriqués à l'intérieur, + # construit exactement comme le contenu de n'importe quel conteneur + # sur un écran — ce petit arbre vit dans un écran-modèle CACHÉ + # (voir "is_template" ci-dessous), pointé par cette colonne. + conn.execute("ALTER TABLE _element_types ADD COLUMN template_screen_id INTEGER REFERENCES _screens(id)") + if "bound_definition_id" not in type_cols: + # "Objet lié" optionnel — comme pour le Répéteur de données, rend + # {{nom_du_champ}} disponible dans tout l'arbre de ce modèle. + conn.execute("ALTER TABLE _element_types ADD COLUMN bound_definition_id INTEGER") + screen_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_screens)").fetchall()} + if "is_template" not in screen_cols: + # Un écran-modèle héberge le contenu d'un élément de jeu réutilisable + # (voir template_screen_id ci-dessus) : il n'apparaît jamais dans la + # liste des écrans du jeu, ni dans le mode jouable, ni dans les + # menus "aller à cet écran" — seul son contenu (les éléments qu'il + # contient) est utilisé, jamais l'écran lui-même. + conn.execute("ALTER TABLE _screens ADD COLUMN is_template INTEGER NOT NULL DEFAULT 0") + if "preview_aspect" not in screen_cols: + # Format d'aperçu choisi pour cet écran (Portrait 9/16, Paysage 16/9, + # ou Carré 1/1) — mémorisé par écran pour qu'il reste le même après + # un ajout/suppression d'élément ou un changement d'élément + # sélectionné (ces actions redemandent le panneau au serveur, qui + # doit donc connaître le choix déjà fait, sinon il repart sur la + # valeur par défaut à chaque fois). Sert aussi en mode Jouer, pour + # garder la zone de jeu à la bonne proportion quelle que soit la + # forme réelle de la fenêtre du joueur (voir #playFrame dans + # play.html) — sans ça, les positions/tailles en % se déforment dès + # que la fenêtre du joueur n'a pas la même proportion que celle + # utilisée à la conception. + conn.execute("ALTER TABLE _screens ADD COLUMN preview_aspect TEXT NOT NULL DEFAULT '9/16'") + action_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_actions)").fetchall()} + for col_name, col_def in [ + ("target_element_id", "INTEGER REFERENCES _screen_elements(id)"), + ("element_property", "TEXT"), + ("element_value", "TEXT"), + ("target_definition_id", "INTEGER"), + ("target_row_id", "INTEGER"), + ("target_field", "TEXT"), + ("data_operation", "TEXT"), + ("data_value", "TEXT"), + ]: + if col_name not in action_cols: + conn.execute(f"ALTER TABLE _actions ADD COLUMN {col_name} {col_def}") + conn.commit() + conn.close() diff --git a/screens/screens_repo/get_screen.py b/screens/screens_repo/get_screen.py new file mode 100644 index 00000000..c8e945f7 --- /dev/null +++ b/screens/screens_repo/get_screen.py @@ -0,0 +1,8 @@ +import db + + +def get_screen(slug, screen_id): + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _screens WHERE id = ?", (screen_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/screens/screens_repo/list_screens.py b/screens/screens_repo/list_screens.py new file mode 100644 index 00000000..cc9cdfbf --- /dev/null +++ b/screens/screens_repo/list_screens.py @@ -0,0 +1,18 @@ +import db + +from .ensure_schema import ensure_schema + + +def list_screens(slug, include_templates=False): + """Par défaut, n'affiche que les vrais écrans du jeu (pas les + écrans-modèles qui hébergent le contenu des éléments de jeu réutilisables + — voir ensure_schema) : c'est ce qu'on veut partout où on énumère "les + écrans du jeu" (liste, menu "aller à cet écran", mode jouable).""" + ensure_schema(slug) + conn = db.connect(slug) + if include_templates: + rows = conn.execute("SELECT * FROM _screens ORDER BY order_index, id").fetchall() + else: + rows = conn.execute("SELECT * FROM _screens WHERE is_template = 0 ORDER BY order_index, id").fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/screens_repo/move_screen.py b/screens/screens_repo/move_screen.py new file mode 100644 index 00000000..95228697 --- /dev/null +++ b/screens/screens_repo/move_screen.py @@ -0,0 +1,21 @@ +import db + +from .list_screens import list_screens + + +def move_screen(slug, screen_id, direction): + """direction: -1 (monter, l'écran devient plus tôt dans l'enchaînement) + ou +1 (descendre) — échange l'order_index avec l'écran voisin.""" + screens_ = list_screens(slug) + idx = next((i for i, s in enumerate(screens_) if s["id"] == screen_id), None) + if idx is None: + return + swap_idx = idx + direction + if swap_idx < 0 or swap_idx >= len(screens_): + return + a, b = screens_[idx], screens_[swap_idx] + conn = db.connect(slug) + conn.execute("UPDATE _screens SET order_index = ? WHERE id = ?", (b["order_index"], a["id"])) + conn.execute("UPDATE _screens SET order_index = ? WHERE id = ?", (a["order_index"], b["id"])) + conn.commit() + conn.close() diff --git a/screens/screens_repo/rename_screen.py b/screens/screens_repo/rename_screen.py new file mode 100644 index 00000000..d3e6d000 --- /dev/null +++ b/screens/screens_repo/rename_screen.py @@ -0,0 +1,8 @@ +import db + + +def rename_screen(slug, screen_id, new_name): + conn = db.connect(slug) + conn.execute("UPDATE _screens SET name = ? WHERE id = ?", (new_name, screen_id)) + conn.commit() + conn.close() diff --git a/screens/screens_repo/screens_referencing.py b/screens/screens_repo/screens_referencing.py new file mode 100644 index 00000000..30296cba --- /dev/null +++ b/screens/screens_repo/screens_referencing.py @@ -0,0 +1,16 @@ +import db + + +def screens_referencing(slug, screen_id): + """Écrans dont une action cible (aller_a) précisément cet écran — pour + avertir avant suppression plutôt que de casser silencieusement le lien.""" + conn = db.connect(slug) + rows = conn.execute( + """SELECT DISTINCT s.id, s.name FROM _actions a + JOIN _screen_elements e ON e.id = a.element_id + JOIN _screens s ON s.id = e.screen_id + WHERE a.action_type = 'aller_a' AND a.target_screen_id = ?""", + (screen_id,), + ).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/screens_repo/set_screen_aspect.py b/screens/screens_repo/set_screen_aspect.py new file mode 100644 index 00000000..a3362907 --- /dev/null +++ b/screens/screens_repo/set_screen_aspect.py @@ -0,0 +1,20 @@ +import db + +from .ensure_schema import ensure_schema + +_ALLOWED = {"9/16", "16/9", "1/1"} + + +def set_screen_aspect(slug, screen_id, aspect): + """Mémorise le format d'aperçu choisi (Portrait/Paysage/Carré) pour cet + écran — voir le commentaire sur la colonne preview_aspect dans + ensure_schema.py. Ignore silencieusement une valeur inconnue plutôt que + de planter, au cas où le client enverrait autre chose que les trois + boutons prévus.""" + if aspect not in _ALLOWED: + return + ensure_schema(slug) + conn = db.connect(slug) + conn.execute("UPDATE _screens SET preview_aspect = ? WHERE id = ?", (aspect, screen_id)) + conn.commit() + conn.close() diff --git a/screens/widgets/__init__.py b/screens/widgets/__init__.py new file mode 100644 index 00000000..736afc6c --- /dev/null +++ b/screens/widgets/__init__.py @@ -0,0 +1 @@ +"""Catalogue de widgets et constructeurs de réglages ("controls").""" diff --git a/screens/widgets/border_style_options.py b/screens/widgets/border_style_options.py new file mode 100644 index 00000000..a9bda987 --- /dev/null +++ b/screens/widgets/border_style_options.py @@ -0,0 +1 @@ +BORDER_STYLE_OPTIONS = [("solid", "Trait plein"), ("dashed", "Tirets"), ("dotted", "Pointillés"), ("none", "Aucune")] diff --git a/screens/widgets/container_widgets.py b/screens/widgets/container_widgets.py new file mode 100644 index 00000000..c147a8b9 --- /dev/null +++ b/screens/widgets/container_widgets.py @@ -0,0 +1 @@ +CONTAINER_WIDGETS = {"conteneur", "repeteur_donnees", "groupe_champs", "superposition"} diff --git a/screens/widgets/control_groups/__init__.py b/screens/widgets/control_groups/__init__.py new file mode 100644 index 00000000..69897cc1 --- /dev/null +++ b/screens/widgets/control_groups/__init__.py @@ -0,0 +1 @@ +"""Groupes de réglages ("controls") réutilisés par plusieurs widgets.""" diff --git a/screens/widgets/control_groups/border_controls.py b/screens/widgets/control_groups/border_controls.py new file mode 100644 index 00000000..13a0ae03 --- /dev/null +++ b/screens/widgets/control_groups/border_controls.py @@ -0,0 +1,20 @@ +from ..controls.c_slider import c_slider +from ..controls.c_color import c_color +from ..controls.c_select import c_select +from ..border_style_options import BORDER_STYLE_OPTIONS + +# Les épaisseurs "par côté" ci-dessous sont volontairement placées APRÈS +# "border-width" (tous les côtés) dans cette même liste : pour une même +# propriété CSS, la déclaration qui apparaît EN DERNIER dans l'attribut +# style l'emporte — un réglage par côté prime donc sur le réglage global +# quand les deux sont utilisés ensemble. La couleur et le style de bordure +# restent communs aux 4 côtés. +BORDER_CONTROLS = [ + c_slider("border-width", "Épaisseur de la bordure, tous les côtés", "style:border-width", min=0, max=20, default=0, group="Bordure"), + c_color("border-color", "Couleur de la bordure", "style:border-color", "#2b3140", group="Bordure"), + c_select("border-style", "Style de la bordure", "style:border-style", BORDER_STYLE_OPTIONS, default="solid", group="Bordure"), + c_slider("border-top-width", "Épaisseur — haut", "style:border-top-width", min=0, max=20, default=0, group="Bordure"), + c_slider("border-right-width", "Épaisseur — droite", "style:border-right-width", min=0, max=20, default=0, group="Bordure"), + c_slider("border-bottom-width", "Épaisseur — bas", "style:border-bottom-width", min=0, max=20, default=0, group="Bordure"), + c_slider("border-left-width", "Épaisseur — gauche", "style:border-left-width", min=0, max=20, default=0, group="Bordure"), +] diff --git a/screens/widgets/control_groups/hover_controls.py b/screens/widgets/control_groups/hover_controls.py new file mode 100644 index 00000000..682e302c --- /dev/null +++ b/screens/widgets/control_groups/hover_controls.py @@ -0,0 +1,21 @@ +from ..controls.c_text import c_text + +# 3.1 (Confort) — interactions au survol : mécanisme générique, disponible +# sur TOUS les widgets (via UNIVERSAL_CONTROLS), pour révéler un texte +# différent quand le joueur survole l'élément avec la souris — le cas +# d'usage documenté : survoler un nom révèle l'adresse réelle, survoler un +# lien révèle l'URL réelle. Laisser vide = aucun changement au survol (donc +# aucune régression sur les jeux existants, dont l'attribut est absent). +# Rendu : voir render_element_html.py (attribut data-hover-text) et +# bindHoverTexts() dans templates/play.html (échange du texte au survol, +# ignore volontairement les éléments qui ont des enfants — conteneurs — pour +# ne jamais écraser une mise en page imbriquée). +HOVER_CONTROLS = [ + c_text( + "survol_texte", + "Texte affiché au survol de la souris", + "attr:_survol_texte", + placeholder="ex. mathilde.d@forgebase.fr, https://vrai-lien.exemple...", + group="Survol", + ), +] diff --git a/screens/widgets/control_groups/layout_controls.py b/screens/widgets/control_groups/layout_controls.py new file mode 100644 index 00000000..d77f1951 --- /dev/null +++ b/screens/widgets/control_groups/layout_controls.py @@ -0,0 +1,44 @@ +from ..controls.c_preset import c_preset +from ..controls.c_checkbox import c_checkbox +from ..controls.c_slider import c_slider + +LAYOUT_CONTROLS = [ + c_preset( + "disposition_interne", "Disposition interne", + [ + ("libre", "Empilement libre", {"display": "", "flex-direction": ""}), + ("ligne", "Alignés côte à côte", {"display": "flex", "flex-direction": "row"}), + ("ligne_inverse", "Alignés côte à côte, ordre inversé", {"display": "flex", "flex-direction": "row-reverse"}), + ("colonne", "Alignés les uns sous les autres", {"display": "flex", "flex-direction": "column"}), + ("colonne_inverse", "Alignés les uns sous les autres, ordre inversé", {"display": "flex", "flex-direction": "column-reverse"}), + ], + default="libre", group="Disposition", + ), + c_preset( + "alignement_transversal", "Alignement transversal", + [ + ("etire", "Étirés pour remplir", {"align-items": ""}), + ("debut", "Alignés au début", {"align-items": "flex-start"}), + ("centre_t", "Centrés", {"align-items": "center"}), + ("fin", "Alignés à la fin", {"align-items": "flex-end"}), + ], + default="etire", group="Disposition", + ), + c_preset( + "alignement_principal", "Répartition", + [ + ("debut_p", "Regroupés au début", {"justify-content": ""}), + ("centre_p", "Regroupés au centre", {"justify-content": "center"}), + ("fin_p", "Regroupés à la fin", {"justify-content": "flex-end"}), + ("espace_entre", "Espacement égal entre eux", {"justify-content": "space-between"}), + ("espace_autour", "Espacement égal autour de chacun", {"justify-content": "space-around"}), + ("espace_uniforme", "Espacement totalement uniforme", {"justify-content": "space-evenly"}), + ], + default="debut_p", group="Disposition", + ), + c_checkbox( + "flex-wrap", "Autoriser le retour à la ligne si ça ne tient pas", + "style:flex-wrap", true_value="wrap", false_value="nowrap", default=False, group="Disposition", + ), + c_slider("gap", "Espace entre les éléments", "style:gap", min=0, max=40, default=8, group="Disposition"), +] diff --git a/screens/widgets/control_groups/size_override_controls.py b/screens/widgets/control_groups/size_override_controls.py new file mode 100644 index 00000000..b46e53f8 --- /dev/null +++ b/screens/widgets/control_groups/size_override_controls.py @@ -0,0 +1,16 @@ +from ..controls.c_slider import c_slider + +# Taille fixe (en pixels), surtout utile pour un élément posé À L'INTÉRIEUR +# d'un conteneur/répéteur/groupe de champs : par défaut, ces éléments +# remplissent la largeur disponible et s'ajustent en hauteur à leur contenu +# (voir _style_string) — 0 garde ce comportement automatique ; une valeur +# non nulle impose une taille fixe (ex: une image de 120px de large dans une +# ligne, pour que "Espacement égal entre eux"/"autour"/"uniforme" sur le +# conteneur parent ait un effet visible, ce qui n'est pas le cas tant que +# chaque élément remplit 100% de la largeur). +SIZE_OVERRIDE_CONTROLS = [ + c_slider("child-width", "Largeur fixe", "style:width", + min=0, max=1000, default=0, group="Taille dans le conteneur"), + c_slider("child-height", "Hauteur fixe", "style:height", + min=0, max=1000, default=0, group="Taille dans le conteneur"), +] diff --git a/screens/widgets/control_groups/spacing_controls.py b/screens/widgets/control_groups/spacing_controls.py new file mode 100644 index 00000000..08939c0b --- /dev/null +++ b/screens/widgets/control_groups/spacing_controls.py @@ -0,0 +1,20 @@ +from ..controls.c_slider import c_slider + +# Les réglages "par côté" sont volontairement placés APRÈS les réglages +# globaux ci-dessous dans cette même liste : le style HTML final est généré +# dans cet ordre, et pour une même propriété CSS (ex. padding-top vs +# padding), la déclaration qui apparaît EN DERNIER dans l'attribut style +# l'emporte — donc un réglage par côté prime bien sur le réglage global s'il +# est utilisé en plus. +SPACING_CONTROLS = [ + c_slider("padding", "Marge intérieure — tous les côtés", "style:padding", min=0, max=60, default=0, group="Espacement"), + c_slider("margin", "Marge extérieure — tous les côtés", "style:margin", min=0, max=60, default=0, group="Espacement"), + c_slider("padding-top", "Padding — haut", "style:padding-top", min=0, max=60, default=0, group="Espacement"), + c_slider("padding-right", "Padding — droite", "style:padding-right", min=0, max=60, default=0, group="Espacement"), + c_slider("padding-bottom", "Padding — bas", "style:padding-bottom", min=0, max=60, default=0, group="Espacement"), + c_slider("padding-left", "Padding — gauche", "style:padding-left", min=0, max=60, default=0, group="Espacement"), + c_slider("margin-top", "Margin — haut", "style:margin-top", min=0, max=60, default=0, group="Espacement"), + c_slider("margin-right", "Margin — droite", "style:margin-right", min=0, max=60, default=0, group="Espacement"), + c_slider("margin-bottom", "Margin — bas", "style:margin-bottom", min=0, max=60, default=0, group="Espacement"), + c_slider("margin-left", "Margin — gauche", "style:margin-left", min=0, max=60, default=0, group="Espacement"), +] diff --git a/screens/widgets/control_groups/text_style_controls_extra.py b/screens/widgets/control_groups/text_style_controls_extra.py new file mode 100644 index 00000000..dfd3ce8d --- /dev/null +++ b/screens/widgets/control_groups/text_style_controls_extra.py @@ -0,0 +1,6 @@ +from ..controls.c_select import c_select +from ..font_options import FONT_OPTIONS + +TEXT_STYLE_CONTROLS_EXTRA = [ + c_select("font-family", "Police de caractère", "style:font-family", FONT_OPTIONS, default="inherit", group="Texte"), +] diff --git a/screens/widgets/control_groups/universal_controls.py b/screens/widgets/control_groups/universal_controls.py new file mode 100644 index 00000000..c18bfb3d --- /dev/null +++ b/screens/widgets/control_groups/universal_controls.py @@ -0,0 +1,8 @@ +from ..controls.c_scale import c_scale +from .visibility_control import VISIBILITY_CONTROL +from .size_override_controls import SIZE_OVERRIDE_CONTROLS +from .spacing_controls import SPACING_CONTROLS +from .border_controls import BORDER_CONTROLS +from .hover_controls import HOVER_CONTROLS + +UNIVERSAL_CONTROLS = [VISIBILITY_CONTROL] + SIZE_OVERRIDE_CONTROLS + SPACING_CONTROLS + BORDER_CONTROLS + [c_scale()] + HOVER_CONTROLS diff --git a/screens/widgets/control_groups/visibility_control.py b/screens/widgets/control_groups/visibility_control.py new file mode 100644 index 00000000..fa1e5e88 --- /dev/null +++ b/screens/widgets/control_groups/visibility_control.py @@ -0,0 +1,16 @@ +from ..controls.c_preset import c_preset + +VISIBILITY_CONTROL = c_preset( + "visibilite", "Visibilité", + [ + # "display" n'est volontairement pas touché par "visible" / "invisible" : + # un conteneur en disposition ligne/colonne (flex) doit garder son + # display:flex quand il redevient visible — seul "masqué" doit + # explicitement forcer display:none (et gagne, car ce réglage est + # appliqué après la disposition interne lors de l'enregistrement). + ("visible", "Visible normalement", {"visibility": ""}), + ("masque", "Masqué — n'occupe plus de place", {"display": "none", "visibility": ""}), + ("invisible", "Invisible — garde sa place", {"visibility": "hidden"}), + ], + default="visible", group="Disposition", +) diff --git a/screens/widgets/control_value.py b/screens/widgets/control_value.py new file mode 100644 index 00000000..ad9dddc0 --- /dev/null +++ b/screens/widgets/control_value.py @@ -0,0 +1,44 @@ +import re + + +def _control_value(el, control): + """Lit la valeur actuelle d'un réglage à partir de l'élément stocké + (content / attributs / style), avec repli sur sa valeur par défaut.""" + ctype = control["type"] + style = el.get("style") or {} + + if ctype == "preset": + for key, _label, props in control["options"]: + if all((style.get(k) or "") == (v or "") for k, v in props.items()): + return key + return control.get("default") + + if ctype == "toggle_token": + _kind, name = control["target"].split(":", 1) + current = style.get(name, "") or "" + return control["token"] in current.split() + + if ctype == "scale": + transform = style.get("transform", "") or "" + m = re.search(r"scale\(([\d.]+)\)", transform) + return float(m.group(1)) if m else control.get("default", 1) + + target = control["target"] + if target == "content": + return el.get("content") or control.get("default", "") + kind, name = target.split(":", 1) + store = el.get("attributes") if kind == "attr" else style + store = store or {} + if ctype == "checkbox": + if name not in store: + return bool(control.get("default", False)) + return store[name] not in (None, "", control.get("false_value")) + if ctype == "slider": + raw = store.get(name) + if raw is None: + return control.get("default", 0) + try: + return float(str(raw).replace(control.get("unit", ""), "").strip() or 0) + except ValueError: + return control.get("default", 0) + return store.get(name, control.get("default", "")) diff --git a/screens/widgets/controls/__init__.py b/screens/widgets/controls/__init__.py new file mode 100644 index 00000000..758e9f53 --- /dev/null +++ b/screens/widgets/controls/__init__.py @@ -0,0 +1 @@ +"""Constructeurs de réglages ("controls") pour les widgets.""" diff --git a/screens/widgets/controls/c_align.py b/screens/widgets/controls/c_align.py new file mode 100644 index 00000000..cde2822f --- /dev/null +++ b/screens/widgets/controls/c_align.py @@ -0,0 +1,2 @@ +def c_align(key, label, target, default="left", group=None): + return {"key": key, "label": label, "type": "align", "target": target, "default": default, "group": group} diff --git a/screens/widgets/controls/c_checkbox.py b/screens/widgets/controls/c_checkbox.py new file mode 100644 index 00000000..f317bf52 --- /dev/null +++ b/screens/widgets/controls/c_checkbox.py @@ -0,0 +1,2 @@ +def c_checkbox(key, label, target, true_value="1", false_value="", default=False, group=None): + return {"key": key, "label": label, "type": "checkbox", "target": target, "true_value": true_value, "false_value": false_value, "default": default, "group": group} diff --git a/screens/widgets/controls/c_color.py b/screens/widgets/controls/c_color.py new file mode 100644 index 00000000..241bcff1 --- /dev/null +++ b/screens/widgets/controls/c_color.py @@ -0,0 +1,2 @@ +def c_color(key, label, target, default="#e8eaf0", group=None): + return {"key": key, "label": label, "type": "color", "target": target, "default": default, "group": group} diff --git a/screens/widgets/controls/c_preset.py b/screens/widgets/controls/c_preset.py new file mode 100644 index 00000000..8cde34c3 --- /dev/null +++ b/screens/widgets/controls/c_preset.py @@ -0,0 +1,3 @@ +def c_preset(key, label, options, default=None, group=None): + """options: liste de (clé, libellé, {propriété_css: valeur, ...}).""" + return {"key": key, "label": label, "type": "preset", "options": options, "default": default or options[0][0], "group": group} diff --git a/screens/widgets/controls/c_scale.py b/screens/widgets/controls/c_scale.py new file mode 100644 index 00000000..ab5f26d6 --- /dev/null +++ b/screens/widgets/controls/c_scale.py @@ -0,0 +1,2 @@ +def c_scale(key="scale", label="Échelle", group="Disposition"): + return {"key": key, "label": label, "type": "scale", "min": 0.1, "max": 3, "step": 0.05, "default": 1, "group": group} diff --git a/screens/widgets/controls/c_select.py b/screens/widgets/controls/c_select.py new file mode 100644 index 00000000..00660480 --- /dev/null +++ b/screens/widgets/controls/c_select.py @@ -0,0 +1,2 @@ +def c_select(key, label, target, options, default=None, group=None): + return {"key": key, "label": label, "type": "select", "target": target, "options": options, "default": default or options[0][0], "group": group} diff --git a/screens/widgets/controls/c_slider.py b/screens/widgets/controls/c_slider.py new file mode 100644 index 00000000..fffd4e0f --- /dev/null +++ b/screens/widgets/controls/c_slider.py @@ -0,0 +1,2 @@ +def c_slider(key, label, target, unit="px", min=0, max=100, step=1, default=16, group=None): + return {"key": key, "label": label, "type": "slider", "target": target, "unit": unit, "min": min, "max": max, "step": step, "default": default, "group": group} diff --git a/screens/widgets/controls/c_text.py b/screens/widgets/controls/c_text.py new file mode 100644 index 00000000..b7b26a7d --- /dev/null +++ b/screens/widgets/controls/c_text.py @@ -0,0 +1,2 @@ +def c_text(key, label, target, placeholder="", default="", group=None): + return {"key": key, "label": label, "type": "text", "target": target, "placeholder": placeholder, "default": default, "group": group} diff --git a/screens/widgets/controls/c_textarea.py b/screens/widgets/controls/c_textarea.py new file mode 100644 index 00000000..1f5499a1 --- /dev/null +++ b/screens/widgets/controls/c_textarea.py @@ -0,0 +1,2 @@ +def c_textarea(key, label, target, placeholder="", default="", group=None): + return {"key": key, "label": label, "type": "textarea", "target": target, "placeholder": placeholder, "default": default, "group": group} diff --git a/screens/widgets/controls/c_toggle_token.py b/screens/widgets/controls/c_toggle_token.py new file mode 100644 index 00000000..6035f6e7 --- /dev/null +++ b/screens/widgets/controls/c_toggle_token.py @@ -0,0 +1,2 @@ +def c_toggle_token(key, label, target, token, group=None): + return {"key": key, "label": label, "type": "toggle_token", "target": target, "token": token, "group": group} diff --git a/screens/widgets/controls/c_url.py b/screens/widgets/controls/c_url.py new file mode 100644 index 00000000..ac8c4350 --- /dev/null +++ b/screens/widgets/controls/c_url.py @@ -0,0 +1,3 @@ +def c_url(key, label, target, placeholder="https://...", default="", allow_upload=False, upload_accept="*/*", group=None): + return {"key": key, "label": label, "type": "url", "target": target, "placeholder": placeholder, "default": default, + "allow_upload": allow_upload, "upload_accept": upload_accept, "group": group} diff --git a/screens/widgets/controls_with_values.py b/screens/widgets/controls_with_values.py new file mode 100644 index 00000000..7086d2c4 --- /dev/null +++ b/screens/widgets/controls_with_values.py @@ -0,0 +1,24 @@ +import db + +from .widget_meta import widget_meta +from .control_value import _control_value +from ..element_types.list_element_types import list_element_types + + +def controls_with_values(slug, el): + """Pour le panneau de propriétés : la liste des réglages du widget de + cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage + "définition_id" du Répéteur de données a besoin du jeu courant pour + lister les objets disponibles — d'où le paramètre slug.""" + meta = widget_meta(el.get("widget")) + result = [] + for control in meta.get("controls", []): + control = dict(control) + if control["key"] == "definition_id": + defs = db.list_definitions(slug) + control["options"] = [("", "(sélectionner un objet)")] + [(str(d["id"]), d["name"]) for d in defs] + if control["key"] == "template_type_id": + types = list_element_types(slug) + control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types] + result.append({**control, "value": _control_value(el, control)}) + return result diff --git a/screens/widgets/default_style_for_widget.py b/screens/widgets/default_style_for_widget.py new file mode 100644 index 00000000..37d1f7a6 --- /dev/null +++ b/screens/widgets/default_style_for_widget.py @@ -0,0 +1,75 @@ +from .widget_meta import widget_meta + +# Ces deux réglages utilisent 0 comme valeur sentinelle "automatique" (voir +# size_override_controls.py / _style_string) : contrairement aux autres +# sliders, 0 n'y est PAS une vraie valeur par défaut à figer, donc on ne les +# fige jamais à la création — les autres sliders à 0 par défaut (ex: +# épaisseur de bordure) doivent au contraire être figés explicitement, sinon +# un border-style baké sans border-width associé afficherait une bordure par +# défaut du navigateur (~3px, "medium") au lieu de rester invisible. +_AUTO_SIZE_KEYS = {"child-width", "child-height"} + +# Réglages "par côté" (padding/margin/bordure individuels) : contrairement +# aux réglages "tous les côtés" équivalents (qui, eux, DOIVENT être figés à +# la création — voir plus bas), ceux-ci ne doivent jamais l'être. Une fois +# figés (donc présents dans le style stocké), save_element_controls.py les +# réécrirait à CHAQUE enregistrement du panneau (puisqu'il envoie tous les +# réglages en un seul formulaire) — et comme ils sont placés APRÈS le +# réglage global correspondant dans les listes de contrôles (pour pouvoir le +# surcharger quand on s'en sert), une valeur par défaut ainsi figée à 0 +# écraserait alors silencieusement tout changement fait sur le réglage +# global (ex: "Épaisseur de la bordure" à 5px se retrouverait avec un côté +# resté à 0px). Ils doivent donc rester ABSENTS du style tant qu'on ne les a +# pas vraiment personnalisés. +_PER_SIDE_KEYS = { + "padding-top", "padding-right", "padding-bottom", "padding-left", + "margin-top", "margin-right", "margin-bottom", "margin-left", + "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", +} + + +def default_style_and_attributes(widget_key): + """Calcule le style/attributs de DÉPART d'un élément fraîchement posé sur + l'écran, pour les réglages dont la valeur par défaut a un effet visuel + voulu dès la création (ex: "Ajustement dans son cadre" = cover pour une + image, afin qu'elle se recadre proprement sans être écrasée) plutôt que + de rester une simple valeur d'aperçu dans le panneau de propriétés. + + Les couleurs (fond, bordure) sont volontairement EXCLUES : leur valeur + "par défaut" n'est qu'une suggestion affichée dans le sélecteur de + couleur, pas un vrai réglage neutre (le neutre CSS est transparent) — la + figer dès la création ferait apparaître une couleur que personne n'a + demandée. Voir save_element_controls.py pour la même règle appliquée + aux modifications ultérieures via le panneau de propriétés.""" + meta = widget_meta(widget_key) + attributes, style = {}, {} + for control in meta.get("controls", []): + ctype = control["type"] + if ctype in ("checkbox", "toggle_token", "preset", "scale", "color"): + continue + target = control.get("target") + if not target or target == "content": + continue + default = control.get("default", "") + if default in (None, ""): + continue + if ctype == "slider": + if control["key"] in _AUTO_SIZE_KEYS and str(default) in ("0",): + continue # 0 = comportement automatique, rien à figer + if control["key"] in _PER_SIDE_KEYS: + continue # réglage par côté : jamais figé (voir commentaire plus haut) + unit = control.get("unit", "") + default = f"{default}{unit}" + kind, name = target.split(":", 1) + (attributes if kind == "attr" else style)[name] = default + + if widget_key == "superposition": + # 3.4 (Confort) — overlay/modale réutilisable : contrairement aux + # autres widgets, une Superposition démarre MASQUÉE par défaut dès + # sa création (position:fixed; inset:0 la ferait sinon couvrir tout + # l'écran immédiatement, avant même qu'une action l'affiche). Le + # créateur du jeu doit poser une action "Modifier un élément → + # Visibilité → Rendre visible" pour l'ouvrir — cohérent avec + # "fermeture manuelle uniquement". + style["display"] = "none" + return attributes, style diff --git a/screens/widgets/font_options.py b/screens/widgets/font_options.py new file mode 100644 index 00000000..d171ac06 --- /dev/null +++ b/screens/widgets/font_options.py @@ -0,0 +1,15 @@ +FONT_OPTIONS = [ + ("inherit", "Police par défaut"), + ("Georgia, serif", "Georgia (classique, avec empattements)"), + ("'Courier New', monospace", "Courier (machine à écrire)"), + ("'Trebuchet MS', sans-serif", "Trebuchet (arrondie)"), + ("'Poppins', sans-serif", "Poppins (Google Fonts, moderne)"), + ("'Roboto', sans-serif", "Roboto (Google Fonts, neutre)"), + ("'Montserrat', sans-serif", "Montserrat (Google Fonts, affirmée)"), + ("'Playfair Display', serif", "Playfair Display (Google Fonts, élégante)"), +] +GOOGLE_FONTS_LINK = ( + "https://fonts.googleapis.com/css2?" + "family=Poppins:wght@400;600;700&family=Roboto:wght@400;700&" + "family=Montserrat:wght@400;700&family=Playfair+Display:wght@400;700&display=swap" +) diff --git a/screens/widgets/layout_capable_widgets.py b/screens/widgets/layout_capable_widgets.py new file mode 100644 index 00000000..aa8476b9 --- /dev/null +++ b/screens/widgets/layout_capable_widgets.py @@ -0,0 +1,8 @@ +# "superposition" (3.4, overlay/modale) est volontairement ABSENT d'ici : +# son style "display" est piloté à 100% par render_overlay.py (position +# fixe + display:flex codés en dur pour le voile, quelle que soit la +# disposition) — lui donner le contrôle "Disposition interne" (LAYOUT_ +# CONTROLS ci-dessous) écraserait le display:none posé par défaut à sa +# création (voir default_style_for_widget.py) dès le premier enregistrement +# du panneau de propriétés, rendant la modale visible en permanence. +LAYOUT_CAPABLE_WIDGETS = {"conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"} diff --git a/screens/widgets/registry.py b/screens/widgets/registry.py new file mode 100644 index 00000000..31c632ee --- /dev/null +++ b/screens/widgets/registry.py @@ -0,0 +1,261 @@ +from .controls.c_text import c_text +from .controls.c_textarea import c_textarea +from .controls.c_url import c_url +from .controls.c_color import c_color +from .controls.c_slider import c_slider +from .controls.c_checkbox import c_checkbox +from .controls.c_toggle_token import c_toggle_token +from .controls.c_align import c_align +from .controls.c_select import c_select +from .control_groups.text_style_controls_extra import TEXT_STYLE_CONTROLS_EXTRA +from ..flow.constants import CONDITION_OPERATORS + +WIDGETS = { + "texte": { + "label": "Texte", "icon": "📝", "tag": "p", "has_content": True, + "w": 40, "h": 10, "default_content": "Texte du paragraphe.", + "controls": [ + c_textarea("content", "Texte", "content", default="Texte du paragraphe.", group="Contenu"), + c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=64, default=16, group="Texte"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + c_align("text-align", "Alignement", "style:text-align", group="Texte"), + c_checkbox("bold", "Gras", "style:font-weight", "bold", "normal", group="Texte"), + c_checkbox("italic", "Italique", "style:font-style", "italic", "normal", group="Texte"), + c_toggle_token("underline", "Souligné", "style:text-decoration", "underline", group="Texte"), + c_toggle_token("strikethrough", "Barré", "style:text-decoration", "line-through", group="Texte"), + ] + TEXT_STYLE_CONTROLS_EXTRA, + }, + "titre": { + "label": "Titre", "icon": "🔠", "tag": None, "has_content": True, + "w": 55, "h": 10, "default_content": "Titre", + "controls": [ + c_textarea("content", "Texte", "content", default="Titre", group="Contenu"), + c_select("niveau", "Importance du titre", "attr:_niveau", + [("1", "Très grand — titre principal"), ("2", "Grand"), ("3", "Moyen")], default="2", group="Contenu"), + c_slider("font-size", "Taille du texte", "style:font-size", min=14, max=80, default=32, group="Texte"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + c_align("text-align", "Alignement", "style:text-align", default="left", group="Texte"), + c_checkbox("italic", "Italique", "style:font-style", "italic", "normal", group="Texte"), + c_toggle_token("underline", "Souligné", "style:text-decoration", "underline", group="Texte"), + ] + TEXT_STYLE_CONTROLS_EXTRA, + }, + "bouton": { + "label": "Bouton", "icon": "🔘", "tag": "button", "has_content": True, + "w": 30, "h": 8, "default_content": "Valider", + "controls": [ + c_text("content", "Texte du bouton", "content", default="Valider", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#ffffff", group="Texte"), + c_color("background", "Couleur de fond", "style:background-color", "#5b8cff", group="Texte"), + c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=15, group="Texte"), + c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=40, default=8, group="Texte"), + c_checkbox("submit", "Valide un formulaire", "attr:type", "submit", "button", group="Contenu"), + ] + TEXT_STYLE_CONTROLS_EXTRA, + }, + "lien": { + "label": "Lien", "icon": "🔗", "tag": "a", "has_content": True, + "w": 25, "h": 6, "default_content": "Cliquez ici", + "controls": [ + c_text("content", "Texte du lien", "content", default="Cliquez ici", group="Contenu"), + c_url("href", "Destination", "attr:href", default="#", group="Contenu"), + c_checkbox("new_tab", "Ouvrir dans un nouvel onglet", "attr:target", "_blank", "", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#5b8cff", group="Texte"), + c_toggle_token("underline", "Souligné", "style:text-decoration", "underline", group="Texte"), + ] + TEXT_STYLE_CONTROLS_EXTRA, + }, + "image": { + "label": "Image", "icon": "🖼️", "tag": "img", "has_content": False, "self_closing": True, + "w": 35, "h": 22, "default_content": "", + "controls": [ + c_url("src", "Source de l'image", "attr:src", allow_upload=True, upload_accept="image/*", group="Contenu"), + c_text("alt", "Texte alternatif", "attr:alt", placeholder="décrit l'image pour l'accessibilité", group="Contenu"), + c_select("object-fit", "Ajustement dans son cadre", "style:object-fit", + [("cover", "Remplir en recadrant"), ("contain", "Tout montrer sans déformer"), ("fill", "Étirer pour remplir tout le cadre")], + default="cover", group="Média"), + c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=60, default=0, group="Média"), + ], + }, + "video": { + "label": "Vidéo", "icon": "🎬", "tag": "video", "has_content": False, + "w": 40, "h": 25, "default_content": "", + "controls": [ + c_url("src", "Source de la vidéo", "attr:src", allow_upload=True, upload_accept="video/*", group="Contenu"), + c_checkbox("controls", "Afficher les contrôles de lecture", "attr:controls", "controls", "", default=True, group="Contenu"), + c_checkbox("autoplay", "Lecture automatique", "attr:autoplay", "autoplay", "", group="Contenu"), + c_checkbox("loop", "Lecture en boucle", "attr:loop", "loop", "", group="Contenu"), + ], + }, + "champ_texte": { + "label": "Champ — texte", "icon": "⌨️", "tag": "input", "has_content": False, "self_closing": True, + "w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "text"}, + "controls": [ + c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Votre réponse", group="Contenu"), + c_text("name", "Nom du champ", "attr:name", placeholder="ex. reponse1", group="Contenu"), + c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), + ], + }, + "champ_email": { + "label": "Champ — email", "icon": "📧", "tag": "input", "has_content": False, "self_closing": True, + "w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "email"}, + "controls": [ + c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. votre@email.com", default="votre@email.com", group="Contenu"), + c_text("name", "Nom du champ", "attr:name", placeholder="ex. email", group="Contenu"), + c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), + ], + }, + "champ_mot_de_passe": { + "label": "Champ — mot de passe", "icon": "🔒", "tag": "input", "has_content": False, "self_closing": True, + "w": 40, "h": 8, "default_content": "", "fixed_attrs": {"type": "password"}, + "controls": [ + c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Mot de passe", group="Contenu"), + c_text("name", "Nom du champ", "attr:name", placeholder="ex. mot_de_passe", group="Contenu"), + c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), + ], + }, + "case_a_cocher": { + "label": "Case à cocher", "icon": "☑️", "tag": "input", "has_content": True, "special_render": "checkbox_label", + "w": 35, "h": 8, "default_content": "Option", + "controls": [ + c_text("content", "Texte affiché", "content", default="Option", group="Contenu"), + c_text("name", "Nom du champ", "attr:name", placeholder="ex. accepte_cgu", group="Contenu"), + c_text("value", "Valeur envoyée", "attr:value", placeholder="ex. oui", group="Contenu"), + c_checkbox("checked", "Coché par défaut", "attr:checked", "checked", "", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + ], + }, + "bouton_radio": { + "label": "Bouton radio", "icon": "🔘", "tag": "input", "has_content": True, "special_render": "checkbox_label", + "w": 35, "h": 8, "default_content": "Option", + "controls": [ + c_text("content", "Texte affiché", "content", default="Option", group="Contenu"), + c_text("name", "Nom du groupe", "attr:name", placeholder="ex. choix1", group="Contenu"), + c_text("value", "Valeur envoyée", "attr:value", placeholder="ex. optionA", group="Contenu"), + c_checkbox("checked", "Sélectionné par défaut", "attr:checked", "checked", "", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + ], + }, + "zone_texte": { + "label": "Zone de texte", "icon": "🗒️", "tag": "textarea", "has_content": False, + "w": 40, "h": 18, "default_content": "", + "controls": [ + c_text("placeholder", "Texte indicatif", "attr:placeholder", placeholder="ex. Votre message", group="Contenu"), + c_text("name", "Nom du champ", "attr:name", placeholder="ex. message", group="Contenu"), + c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), + ], + }, + "liste_deroulante": { + "label": "Liste déroulante", "icon": "🔽", "tag": "select", "has_content": True, "special_render": "select_options", + "w": 35, "h": 8, "default_content": "Option 1\nOption 2\nOption 3", + "controls": [ + c_textarea("content", "Options — une par ligne", "content", default="Option 1\nOption 2\nOption 3", group="Contenu"), + c_text("name", "Nom du champ", "attr:name", placeholder="ex. choix", group="Contenu"), + c_checkbox("required", "Obligatoire", "attr:required", "required", "", group="Contenu"), + ], + }, + "groupe_champs": { + "label": "Groupe de champs", "icon": "🗃️", "tag": "fieldset", "has_content": True, "special_render": "fieldset_legend", + "w": 55, "h": 30, "default_content": "Groupe de champs", + "controls": [ + c_text("content", "Titre du groupe", "content", default="Groupe de champs", group="Contenu"), + ], + }, + "liste": { + "label": "Liste à puces", "icon": "📋", "tag": "ul", "has_content": True, + "w": 40, "h": 22, "default_content": "Premier élément\nDeuxième élément\nTroisième élément", + "controls": [ + c_textarea("content", "Éléments de la liste — un par ligne", "content", + default="Premier élément\nDeuxième élément\nTroisième élément", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"), + ], + }, + "liste_numerotee": { + "label": "Liste numérotée", "icon": "🔢", "tag": "ol", "has_content": True, + "w": 40, "h": 22, "default_content": "Premier élément\nDeuxième élément\nTroisième élément", + "controls": [ + c_textarea("content", "Éléments de la liste — un par ligne", "content", + default="Premier élément\nDeuxième élément\nTroisième élément", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"), + ], + }, + "tableau": { + "label": "Tableau", "icon": "🗂️", "tag": "table", "has_content": True, "special_render": "table", + "w": 55, "h": 25, "default_content": "Colonne 1;Colonne 2\nValeur A;Valeur B", + "controls": [ + c_textarea("content", "Contenu — une ligne par rangée, colonnes séparées par ;", "content", + default="Colonne 1;Colonne 2\nValeur A;Valeur B", group="Contenu"), + c_checkbox("header_row", "Première ligne = en-tête", "attr:_header_row", "1", "", default=True, group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + ], + }, + "conteneur": { + "label": "Conteneur / décor", "icon": "⬛", "tag": "div", "has_content": True, + "w": 50, "h": 30, "default_content": "", + "controls": [ + c_text("content", "Texte", "content", group="Contenu"), + c_color("background", "Couleur de fond", "style:background-color", "#1f2430", group="Texte"), + c_slider("border-radius", "Arrondi des coins", "style:border-radius", min=0, max=60, default=10, group="Texte"), + c_slider("opacity", "Opacité", "style:opacity", unit="", min=0, max=1, step=0.05, default=1, group="Texte"), + ], + }, + "separateur": { + "label": "Séparateur", "icon": "➖", "tag": "hr", "has_content": False, "self_closing": True, + "w": 50, "h": 2, "default_content": "", + "controls": [ + c_color("color", "Couleur", "style:border-color", "#2b3140", group="Texte"), + ], + }, + "repeteur_donnees": { + "label": "Répéteur de données", "icon": "🔁", "tag": "div", "has_content": True, "special_render": "repeater", + "w": 55, "h": 35, "default_content": "{{nom}}", + "controls": [ + c_select("definition_id", "Objet de données à afficher", "attr:_definition_id", + [("", "Sélectionner un objet")], default="", group="Contenu"), + c_select("template_type_id", "Modèle de ligne — élément de jeu", "attr:_template_type_id", + [("", "Aucun — utiliser le modèle de texte ci-dessous")], default="", group="Contenu"), + c_textarea("content", "Modèle d'affichage texte — utilise {{nom_du_champ}}", "content", default="{{nom}}", group="Contenu"), + c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"), + c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"), + c_text("filtre_champ", "Filtrer : n'afficher que les lignes où ce champ", + "attr:_filtre_champ", placeholder="ex. niveau", default="", group="Filtre"), + c_select("filtre_operateur", "...est", "attr:_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"), + c_text("filtre_valeur", "...cette valeur, ou {{NomObjet.champ}} pour comparer à une donnée qui change en cours de partie", + "attr:_filtre_valeur", placeholder="ex. 3 ou {{Partie.niveau_courant}}", default="", group="Filtre"), + ], + }, + "jauge": { + "label": "Jauge — liée à une donnée", "icon": "📊", "tag": "div", "has_content": False, "special_render": "jauge", + "w": 60, "h": 12, "default_content": "", + "controls": [ + # La clé "definition_id" est reconnue spécifiquement dans + # controls_with_values.py pour être peuplée avec les VRAIS + # objets de données du jeu — même mécanisme que le Répéteur. + c_select("definition_id", "Objet de données à suivre", "attr:_definition_id", + [("", "Sélectionner un objet")], default="", group="Donnée"), + c_text("champ", "Champ numérique à afficher", + "attr:_champ", placeholder="ex. reputation", default="", group="Donnée"), + c_text("min", "Valeur mini", "attr:_min", default="0", group="Donnée"), + c_text("max", "Valeur maxi", "attr:_max", default="100", group="Donnée"), + c_color("couleur_basse", "Couleur côté mini", "attr:_couleur_basse", "#e05252", group="Apparence"), + c_color("couleur_haute", "Couleur côté maxi", "attr:_couleur_haute", "#4caf50", group="Apparence"), + c_checkbox("afficher_valeur", "Afficher la valeur en chiffres sur la jauge", "attr:_afficher_valeur", "1", "", default=False, group="Apparence"), + ], + }, + "superposition": { + # 3.4 (Confort) — overlay/modale réutilisable : couvre TOUJOURS tout + # l'écran (position:fixed, indépendant de x/y/width/height — voir + # render_overlay.py), avec un voile semi-transparent + une boîte + # centrée qui contient les éléments posés à l'intérieur. Fermeture + # manuelle uniquement : réutilise l'action existante "Modifier un + # élément → Visibilité" (masquer/rendre visible), comme n'importe + # quel autre élément — pas de mécanisme de fermeture au clic externe. + "label": "Superposition / boîte de dialogue", "icon": "🪟", "tag": "div", "has_content": True, + "special_render": "overlay", + "w": 50, "h": 40, "default_content": "", + "controls": [ + c_text("content", "Texte — en plus des éléments posés dans la boîte", "content", group="Contenu"), + c_color("couleur_boite", "Couleur de fond de la boîte", "attr:_couleur_boite", "#1f2430", group="Apparence"), + c_slider("arrondi", "Arrondi des coins de la boîte", "attr:_arrondi", unit="", min=0, max=40, default=12, group="Apparence"), + ], + }, +} diff --git a/screens/widgets/widget_meta.py b/screens/widgets/widget_meta.py new file mode 100644 index 00000000..a92169f9 --- /dev/null +++ b/screens/widgets/widget_meta.py @@ -0,0 +1,14 @@ +from .registry import WIDGETS +from .layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS +from .control_groups.layout_controls import LAYOUT_CONTROLS +from .control_groups.universal_controls import UNIVERSAL_CONTROLS + + +def widget_meta(widget_key): + base = dict(WIDGETS.get(widget_key, WIDGETS["conteneur"])) + controls = list(base.get("controls", [])) + if widget_key in LAYOUT_CAPABLE_WIDGETS: + controls = controls + LAYOUT_CONTROLS + controls = controls + UNIVERSAL_CONTROLS + base["controls"] = controls + return base diff --git a/static/object_form.js b/static/object_form.js new file mode 100644 index 00000000..bdda379e --- /dev/null +++ b/static/object_form.js @@ -0,0 +1,38 @@ +// object_form.js — ajoute/retire des lignes de champ dans le formulaire de +// définition d'objet, bascule l'affichage du sélecteur "objet lié" +// uniquement quand le type choisi est "relation", et l'affichage des +// bornes mini/maxi (2.2 — bornage automatique) uniquement pour un champ +// numérique. + +const fieldsList = document.getElementById('fieldsList'); +const template = document.getElementById('fieldRowTemplate'); +const btnAddField = document.getElementById('btnAddField'); +const NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal']; + +function wireRow(row) { + const typeSelect = row.querySelector('.fieldTypeSelect'); + const relationWrap = row.querySelector('.relationTarget'); + const boundsWrap = row.querySelector('.boundsTarget'); + function syncFieldTypeVisibility() { + relationWrap.style.display = typeSelect.value === 'relation' ? 'block' : 'none'; + if (boundsWrap) boundsWrap.style.display = NUMERIC_FIELD_TYPES.includes(typeSelect.value) ? 'flex' : 'none'; + } + typeSelect.addEventListener('change', syncFieldTypeVisibility); + syncFieldTypeVisibility(); + + row.querySelector('.btnRemoveField').addEventListener('click', () => { + row.remove(); + }); +} + +function addFieldRow() { + const clone = template.content.cloneNode(true); + const row = clone.querySelector('.fieldRowCard'); + fieldsList.appendChild(clone); + wireRow(fieldsList.lastElementChild); +} + +btnAddField.addEventListener('click', addFieldRow); + +// Toujours démarrer avec un premier champ prêt à remplir. +addFieldRow(); diff --git a/static/pjax.js b/static/pjax.js new file mode 100644 index 00000000..3aa18bac --- /dev/null +++ b/static/pjax.js @@ -0,0 +1,146 @@ +/* + * pjax.js — navigation sans rechargement de page complet. + * + * Principe : le serveur ne change pas (mêmes routes Flask, mêmes pages + * 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 + * , la classe du <body>, l'en-tête (fil d'Ariane) 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. + * + * Les scripts propres à chaque page (dans le bloc "content", donc réinjectés + * à chaque changement de page) ne s'exécutent pas automatiquement quand on + * les insère via innerHTML — on les rejoue explicitement à chaque + * navigation (voir runScriptsIn ci-dessous). C'est pour ça que ces scripts + * ne doivent déclarer aucune variable globale avec `let`/`const` au premier + * niveau (une seconde exécution lèverait "already declared") — seuls `var` + * et les déclarations de fonction sont sûrs à rejouer, ce qui est déjà le + * cas de tous les scripts actuels du moteur. + * + * Pour désactiver ce comportement sur un lien ou un formulaire précis (cas + * rare), ajouter l'attribut data-no-pjax. + */ +(function () { + "use strict"; + + function isInternalUrl(url) { + return url.origin === location.origin; + } + + function runScriptsIn(container) { + var scripts = container.querySelectorAll("script"); + scripts.forEach(function (old) { + var fresh = document.createElement("script"); + for (var i = 0; i < old.attributes.length; i++) { + var attr = old.attributes[i]; + fresh.setAttribute(attr.name, attr.value); + } + fresh.textContent = old.textContent; + old.replaceWith(fresh); + }); + } + + function mergeHeadAssets(newHead) { + // N'ajoute que ce qui manque encore (ex. la police Google Fonts propre + // à l'éditeur d'écran) — ne retire jamais rien, un lien en trop est sans + // conséquence alors que le retirer pourrait faire clignoter le rendu. + var current = document.head; + var currentHrefs = Array.prototype.map.call( + current.querySelectorAll("link[href]"), + function (l) { return l.getAttribute("href"); } + ); + newHead.querySelectorAll("link[rel='stylesheet']").forEach(function (link) { + var href = link.getAttribute("href"); + if (href && currentHrefs.indexOf(href) === -1) { + current.appendChild(link.cloneNode(true)); + } + }); + } + + function swapDocument(html, url, push) { + var doc = new DOMParser().parseFromString(html, "text/html"); + + var newMain = doc.querySelector("main.content"); + var currentMain = document.querySelector("main.content"); + if (!newMain || !currentMain) { + // Page inattendue (pas une page du moteur, ex. redirection externe) : + // par sécurité, on fait une vraie navigation plutôt que d'afficher un + // contenu à moitié cohérent. + location.href = url; + return; + } + + document.title = doc.title; + document.body.className = doc.body.className; + 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; + } + + mergeHeadAssets(doc.head); + runScriptsIn(currentMain); + + if (push) { + history.pushState({ pjax: true }, "", url); + } + window.scrollTo(0, 0); + document.dispatchEvent(new CustomEvent("pjax:navigated", { detail: { url: url } })); + } + + function navigate(url, push, fetchOpts) { + fetch(url, Object.assign({ headers: { "X-Requested-With": "pjax" } }, fetchOpts || {})) + .then(function (resp) { + return resp.text().then(function (html) { + return { html: html, finalUrl: resp.url || url }; + }); + }) + .then(function (result) { + swapDocument(result.html, result.finalUrl, push); + }) + .catch(function () { + // Échec réseau ou réponse inexploitable : on retombe sur une vraie + // navigation plutôt que de laisser l'utilisateur bloqué. + location.href = url; + }); + } + + document.addEventListener("click", function (e) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + var a = e.target.closest("a"); + if (!a || !a.href) return; + if (a.target === "_blank" || a.hasAttribute("download") || a.hasAttribute("data-no-pjax")) return; + if (a.getAttribute("href") && a.getAttribute("href").charAt(0) === "#") return; + var url = new URL(a.href, location.href); + if (!isInternalUrl(url)) return; + if (url.href === location.href.split("#")[0]) return; // lien vers soi-même : rien à refaire + e.preventDefault(); + navigate(url.href, true); + }); + + document.addEventListener("submit", function (e) { + if (e.defaultPrevented) return; // ex: confirm() annulé par l'utilisateur + var form = e.target; + if (!(form instanceof HTMLFormElement)) return; + if (form.target === "_blank" || form.hasAttribute("data-no-pjax")) return; + var url = new URL(form.getAttribute("action") || location.href, location.href); + if (!isInternalUrl(url)) return; + var method = (form.getAttribute("method") || "GET").toUpperCase(); + e.preventDefault(); + if (method === "GET") { + url.search = new URLSearchParams(new FormData(form)).toString(); + navigate(url.href, true); + } else { + navigate(url.href, true, { method: "POST", body: new FormData(form) }); + } + }); + + window.addEventListener("popstate", function () { + navigate(location.href, false); + }); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 00000000..674c0dd2 --- /dev/null +++ b/static/style.css @@ -0,0 +1,288 @@ +:root{ + --bg:#12151c; --panel:#191d27; --panel2:#1f2430; --border:#2b3140; + --text:#e8eaf0; --text-dim:#8b93a7; --accent:#5b8cff; --danger:#e2685f; +} +*{ box-sizing:border-box; } +body{ margin:0; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--text); } +h1{ font-size:20px; margin:0 0 6px; } +h2{ font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-dim); margin:24px 0 8px; } +h3{ font-size:13px; color:var(--accent); margin:14px 0 6px; } +label{ display:block; font-size:12px; color:var(--text-dim); margin:8px 0 4px; } +input[type=text], input[type=number], input[type=date], textarea, select{ + width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:6px; + color:var(--text); padding:8px 10px; font-size:13px; font-family:inherit; +} +input[type=checkbox]{ width:auto; margin-top:6px; } + +.topbar{ display:flex; align-items:center; gap:10px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--border); } +.brand{ color:var(--text); font-weight:700; text-decoration:none; } +.crumb{ color:var(--text-dim); text-decoration:none; font-size:13px; } +.crumb-sep{ color:var(--text-dim); } +.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; } +.inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; } +.inline-form .fieldRow input{ flex:1; } +.fieldRow{ display:flex; gap:10px; } +.fieldRow > div{ flex:1; } +.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; } +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); } +.addBtn{ display:inline-block; margin:10px 0; padding:9px 14px; border-radius:6px; border:1px dashed var(--border); color:var(--accent); text-decoration:none; font-size:13px; } +.actionsRow{ margin-top:16px; } + +.list{ display:flex; flex-direction:column; gap:6px; } +.listRow{ display:block; padding:10px 12px; border:1px solid var(--border); border-radius:8px; background:var(--panel); text-decoration:none; color:var(--text); } +.listRow:hover{ background:var(--panel2); } +.listRowFlex{ display:flex; align-items:center; justify-content:space-between; gap:10px; } +.listRowFlex .rowLink{ flex:1; text-decoration:none; color:var(--text); } +.listRowFlex form{ margin:0; } +.rowTitle{ font-size:14px; } +.rowSub{ font-size:11px; color:var(--text-dim); margin-top:2px; } +.rowActions{ display:flex; gap:6px; align-items:center; } + +.hint{ font-size:11.5px; color:var(--text-dim); line-height:1.5; } +.autosaveStatus{ display:inline-flex; align-items:center; gap:6px; transition:color .2s ease; } +.autosaveStatus.saving{ color:var(--text-dim); } +.autosaveStatus.saved{ color:#7bd88f; } +.warningBanner{ border:1px solid var(--danger); background:rgba(226,104,95,.1); color:var(--text); + border-radius:8px; padding:10px 14px; font-size:12.5px; margin:12px 0; } +.dangerZone{ border:1px solid rgba(226,104,95,.4); border-radius:10px; padding:14px; margin-top:20px; } +.dangerZone h3{ color:var(--danger); } +.fieldsExistingRow{ display:flex; align-items:center; justify-content:space-between; + padding:8px 10px; border:1px solid var(--border); border-radius:6px; margin-bottom:6px; font-size:12.5px; } +.fieldsExistingRow form{ margin:0; } + +.fieldRowCard{ margin-bottom:10px; } +.relationTarget{ margin-top:6px; } + +.dataTable{ width:100%; border-collapse:collapse; font-size:12.5px; } +.dataTable th, .dataTable td{ border:1px solid var(--border); padding:6px 8px; text-align:left; } +.dataTable th{ background:var(--panel2); color:var(--text-dim); } + +.content-wide{ max-width:1080px; } +.twoCol{ display:flex; gap:24px; align-items:flex-start; margin-top:10px; } +.twoCol .colLeft{ flex:0 0 340px; } +.twoCol .colRight{ flex:1; min-width:0; } +.fieldEditCard{ display:flex; flex-direction:column; gap:10px; } +.fieldEditCard input[disabled]{ color:var(--text-dim); opacity:.8; } +.fieldEditActions{ margin-top:6px; } +@media (max-width: 820px){ + .twoCol{ flex-direction:column; } + .twoCol .colLeft{ flex:none; width:100%; } +} + +/* ---------- Éditeur d'écrans (façon Elementor/Bricks) ---------- */ +/* La page de l'éditeur d'écran ne doit jamais défiler elle-même : elle + occupe exactement la hauteur de la fenêtre (topbar + zone de travail), et + 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{ flex:0 0 auto; } +body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; } + +.content-builder{ max-width:1600px; } +.content-builder > .fieldRow:first-child{ flex:0 0 auto; } + +/* 3 colonnes : ajout d'éléments (gauche) / écran en grand (centre) / propriétés (droite) — + toutes les trois occupent exactement la même hauteur (celle qui reste + disponible), avec leur propre défilement interne si leur contenu dépasse. */ +.builder3{ display:flex; gap:20px; align-items:stretch; margin-top:14px; flex:1 1 auto; min-height:0; } +.builderPanel{ flex:0 0 260px; min-width:0; height:100%; overflow-y:auto; } +.builderPanelRight{ flex:0 0 320px; height:100%; overflow-y:auto; } +.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow-y:auto; display:flex; flex-direction:column; } + +.widgetGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.widgetBtnForm{ margin:0; } +.widgetBtn{ + width:100%; display:flex; flex-direction:column; align-items:center; gap:4px; + padding:12px 6px; border:1px solid var(--border); border-radius:10px; background:var(--panel); + color:var(--text); cursor:pointer; +} +.widgetBtn:hover{ border-color:var(--accent); background:var(--panel2); } +.widgetIcon{ font-size:22px; } +.widgetLabel{ font-size:11px; text-align:center; line-height:1.2; color:var(--text-dim); } + +.canvasToolbar{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px; } +.aspectBtn.active{ background:var(--accent); border-color:var(--accent); color:#fff; } + +.canvasFrame{ + width:100%; padding:10px; border:1px solid var(--border); border-radius:16px; background:var(--panel); +} +.canvas{ + position:relative; width:100%; max-width:100%; margin:0 auto; background:#0b0d12; border-radius:8px; + overflow:hidden; border:1px solid var(--border); +} +.canvasElement{ + /* Pas d'overflow:hidden ici : une échelle (transform:scale) ou une + animation posée sur cet élément (voir la Timeline d'animation) vit sur + CE cadre (voir filters/element_style_filter.py) — le rogner clippait + l'effet à la taille d'origine de la boîte au lieu de le laisser + grossir/bouger avec elle. La zone de l'écran elle-même (.canvas, + ci-dessus) reste rognée, donc rien ne peut déborder de l'écran final. */ + box-sizing:border-box; cursor:move; user-select:none; + border:1px dashed transparent; +} +.canvasElement:hover{ border-color:rgba(91,140,255,.5); } +.canvasElement.selected{ border:2px solid var(--accent); } +.canvasElementInner{ width:100%; height:100%; pointer-events:none; } +.resizeHandle{ + position:absolute; right:0; bottom:0; width:14px; height:14px; cursor:nwse-resize; + background:var(--accent); border-radius:3px 0 6px 0; opacity:.85; +} + +.listRow.selected{ border-color:var(--accent); background:var(--panel2); } + +.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); } +.childRow{ padding:7px 10px; font-size:12px; } +.childRow .rowTitle{ font-size:12px; } + +.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); } +.propGroup > summary{ + list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent); + font-weight:600; user-select:none; display:flex; align-items:center; justify-content:space-between; +} +.propGroup > summary::-webkit-details-marker{ display:none; } +.propGroup > summary::after{ content:"▸"; color:var(--text-dim); font-size:11px; transition:transform .15s; } +.propGroup[open] > summary::after{ transform:rotate(90deg); } +.propGroup > summary:hover{ background:var(--panel2); } +.propGroupBody{ padding:2px 12px 12px; } +.propGroupBody h3{ margin-top:0; } + +/* Le groupe "Ajouter DANS ce conteneur" doit être visuellement impossible à + confondre avec "Ajouter un élément sur l'écran" (panneau de gauche) — + même icônes de widgets, même mise en page, mais une cible totalement + différente (à l'intérieur du conteneur sélectionné, pas sur l'écran). */ +.containerContentGroup{ border-color:#3d6b3d; background:#152015; } +.containerContentGroup > summary{ color:#8fd98f; } + +.posGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px 12px; margin-bottom:14px; } +.posGrid label{ font-size:11px; color:var(--text-dim); display:flex; flex-direction:column; gap:4px; } +.posGrid input{ background:var(--panel2); border:1px solid var(--border); border-radius:6px; color:var(--text); padding:6px 8px; } + +.controlRow{ margin-bottom:12px; } +.controlRow > label{ display:block; font-size:12px; color:var(--text-dim); margin-bottom:4px; } +.sliderRow{ display:flex; align-items:center; gap:10px; } +.sliderRow input[type=range]{ flex:1; } +.sliderValue{ font-size:12px; color:var(--text-dim); min-width:44px; text-align:right; } +.alignGroup{ display:flex; gap:6px; } +.alignOption{ flex:1; text-align:center; padding:8px 0; border:1px solid var(--border); border-radius:6px; cursor:pointer; } +.alignOption input{ display:none; } +.alignOption.active{ border-color:var(--accent); background:rgba(91,140,255,.15); } +input[type=color]{ width:52px; height:34px; padding:2px; } + +.propsPanel .sectionDivider, .builderPanelRight .sectionDivider{ border:none; border-top:1px solid var(--border); margin:14px 0; } +.actionAddForm{ margin-top:10px; } + +.uploadRow{ margin-top:6px; } +.uploadInput{ font-size:11px; color:var(--text-dim); } +.uploadStatus{ display:block; margin-top:4px; } +code{ background:var(--panel2); border:1px solid var(--border); border-radius:4px; padding:1px 6px; font-size:11.5px; } + +@media (max-width: 1300px){ + /* Sous cette largeur, les 3 colonnes s'empilent : le principe "tout tient + dans la fenêtre, chaque zone défile pour elle-même" n'a plus de sens + (il n'y a plus qu'une seule colonne) — on revient à une page classique + qui défile normalement. */ + body.builderBody{ height:auto; overflow:visible; } + body.builderBody > main.content{ overflow:visible; } + .builder3{ flex-direction:column; flex:none; } + .builderPanel, .builderPanelRight, .builderCanvasArea{ flex:none; width:100%; height:auto; max-height:none; overflow:visible; } +} + +/* ---------- Logique de la scène : éditeur de flow à nœuds ---------- */ +/* Panneau du bas, dans le même flux flexbox que .builder3 (pas une + surcouche/overlay) : replié, il ne prend que la hauteur de son en-tête et + .builder3 récupère tout le reste de l'espace ; ouvert, sa hauteur est + fixée en pixels (réglable à la souris via la poignée juste au-dessus) et + .builder3 rétrécit d'autant automatiquement — jamais l'un par-dessus + l'autre. */ +.flowPanel{ + flex:0 0 auto; border:1px solid var(--border); border-radius:10px; background:var(--panel); + margin-top:8px; display:flex; flex-direction:column; overflow:hidden; +} +.flowPanelHeader{ + flex:0 0 auto; padding:10px 16px; font-weight:700; cursor:pointer; user-select:none; + display:flex; align-items:center; gap:6px; +} +.flowPanelHeader:hover{ background:var(--panel2); } +.flowToggleIcon{ color:var(--text-dim); font-size:11px; transition:transform .15s; display:inline-block; } +.flowPanel.open .flowToggleIcon{ transform:rotate(90deg); } +.flowPanelBody{ flex:1 1 auto; min-height:0; padding:0 16px 16px; display:none; flex-direction:column; } +.flowPanel.open .flowPanelBody{ display:flex; } +.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; } + +.flowResizeHandle{ + flex:0 0 auto; height:10px; margin-top:6px; cursor:row-resize; position:relative; display:none; +} +.flowResizeHandle.visible{ display:block; } +.flowResizeHandle::before{ + content:""; position:absolute; left:50%; top:3px; width:60px; height:4px; + background:var(--border); border-radius:2px; transform:translateX(-50%); +} +.flowResizeHandle:hover::before{ background:var(--accent); } + +.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; } +.nodeForm label{ margin-top:8px; } + +.flowCanvasWrap{ position:relative; width:100%; flex:1 1 auto; min-height:120px; overflow:auto; border:1px dashed var(--border); border-radius:8px; background:#0d0f15; } +.flowCanvas{ position:relative; width:2200px; height:1400px; } +.flowEdgesSvg{ position:absolute; top:0; left:0; width:2200px; height:1400px; pointer-events:none; z-index:0; } +.flowEdgeLine{ stroke:var(--accent); stroke-width:2; pointer-events:stroke; cursor:pointer; } +.flowEdgeLine.flowEdgeTrue{ stroke:#4caf7d; } +.flowEdgeLine.flowEdgeFalse{ stroke:var(--danger); } + +.flowNode{ + position:absolute; width:220px; min-height:52px; border-radius:8px; border:1px solid var(--border); + background:var(--panel2); padding:10px 12px; font-size:12.5px; cursor:grab; z-index:1; user-select:none; +} +.flowNode-trigger{ border-color:#5b8cff; } +.flowNode-condition{ border-color:#e0b23c; } +.flowNode-action{ border-color:#4caf7d; } +.flowNodeBody{ padding-right:16px; line-height:1.4; word-break:break-word; } +.flowNodeDelete{ + position:absolute; top:4px; right:4px; width:18px; height:18px; padding:0; line-height:1; + font-size:11px; border-radius:4px; background:transparent; border:none; color:var(--text-dim); +} +.flowNodeDelete:hover{ color:var(--danger); } +.flowPort{ + position:absolute; width:14px; height:14px; border-radius:50%; background:var(--panel); + border:2px solid var(--accent); top:50%; transform:translateY(-50%); cursor:crosshair; z-index:2; +} +.flowPortIn{ left:-8px; } +.flowPortOut{ right:-8px; } +.flowPortTrue{ right:-8px; top:35%; border-color:#4caf7d; } +.flowPortFalse{ right:-8px; top:65%; border-color:var(--danger); } + +/* ---------- Timeline d'animation : panneau sous "Logique de la scène" ---------- */ +.animTimelineWrap{ + position:relative; width:100%; flex:1 1 auto; min-height:120px; overflow:auto; + border:1px dashed var(--border); border-radius:8px; background:#0d0f15; +} +.animRuler{ display:flex; height:22px; border-bottom:1px solid var(--border); position:sticky; top:0; background:#0d0f15; z-index:3; } +.animRulerSpacer{ position:sticky; left:0; width:140px; flex:0 0 140px; background:var(--panel); z-index:2; border-right:1px solid var(--border); } +.animRulerTicksWrap{ position:relative; flex:0 0 auto; } +.animRulerTick{ + position:absolute; top:0; bottom:0; font-size:10px; color:var(--text-dim); + border-left:1px solid var(--border); padding-left:3px; +} +.animTracks{ position:relative; } +.animTrack{ position:relative; height:38px; border-bottom:1px solid var(--border); display:flex; align-items:stretch; } +.animTrackLabel{ + position:sticky; left:0; width:140px; flex:0 0 140px; background:var(--panel); z-index:2; + padding:0 8px; font-size:12px; display:flex; align-items:center; border-right:1px solid var(--border); + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; +} +.animTrackLane{ position:relative; flex:0 0 auto; } +.animClip{ + position:absolute; top:5px; height:28px; border-radius:6px; background:var(--accent); color:#fff; + font-size:11px; padding:5px 8px; cursor:grab; overflow:hidden; white-space:nowrap; user-select:none; + box-sizing:border-box; +} +.animClip.selected{ outline:2px solid #fff; } +.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; } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 00000000..81731cdf --- /dev/null +++ b/templates/base.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html lang="fr"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>{% block title %}Forge Engine{% endblock %} + +{% block extra_head %}{% endblock %} + + +
    + 🛠️ Forge Engine + {% block breadcrumb %}{% endblock %} +
    +
    + {% block content %}{% endblock %} +
    + + + diff --git a/templates/data_form.html b/templates/data_form.html new file mode 100644 index 00000000..07002dde --- /dev/null +++ b/templates/data_form.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}{{ 'Modifier' if row else 'Ajouter' }} — {{ definition.name }}{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/ +{{ definition.name }} +/{{ 'Modifier #' ~ row.id if row else 'Nouvelle entrée' }} +{% endblock %} +{% block content %} +

    {{ 'Modifier l\'entrée #' ~ row.id if row else 'Ajouter une entrée' }} — {{ definition.name }}

    +

    Formulaire généré automatiquement à partir de la définition de cet objet — aucun champ n'est codé en dur ici.

    + +
    + {% for f in definition.fields %} + {% set col = f.name|colname %} + {% set current = row[col] if row else none %} +
    + + + {% if f.type == 'texte' %} + + + {% elif f.type == 'texte_long' %} + + + {% elif f.type == 'nombre_entier' %} + + + {% elif f.type == 'nombre_decimal' %} + + + {% elif f.type == 'booleen' %} + + + {% elif f.type == 'date' %} + + + {% elif f.type == 'relation' %} + {% set current_rel = row[col ~ '_id'] if row else none %} + + {% if not relation_options.get(f.id) %} +

    Aucune entrée n'existe encore pour l'objet lié — crée-en une d'abord si tu veux pouvoir la sélectionner ici.

    + {% endif %} + {% endif %} +
    + {% endfor %} + +
    + +
    +
    +{% endblock %} diff --git a/templates/data_list.html b/templates/data_list.html new file mode 100644 index 00000000..6cb75b72 --- /dev/null +++ b/templates/data_list.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}{{ definition.name }} — {{ game.name }}{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/{{ definition.name }} +{% endblock %} +{% block content %} +

    {{ definition.name }} + ✏️ +

    +

    Table SQL : {{ definition.table_name }} — {{ definition.fields|length }} champ(s)

    + +{% if referenced_by %} +
    + ⚠️ 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. +
    +{% endif %} + +{% if blocked_row_id %} +
    + ⚠️ 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. +
    +{% endif %} + ++ Ajouter une entrée + +

    Données enregistrées

    +{% if rows %} + + + + + {% for f in definition.fields %}{% endfor %} + + + + + {% for r in rows %} + + + {% for f in definition.fields %} + {% if f.type == 'relation' %} + {% set rel_id = r[(f.name|colname) ~ '_id'] %} + + {% elif f.type == 'booleen' %} + + {% else %} + + {% endif %} + {% endfor %} + + + {% endfor %} + +
    #{{ f.name }}
    {{ r.id }}{{ relation_labels.get(f.id, {}).get(rel_id, '—') }}{{ 'Oui' if r[f.name|colname] else 'Non' }}{{ r[f.name|colname] }} +
    + ✏️ +
    + +
    +
    +
    +{% else %} +

    Aucune donnée enregistrée pour l'instant.

    +{% endif %} +{% endblock %} diff --git a/templates/element_types.html b/templates/element_types.html new file mode 100644 index 00000000..081a0b10 --- /dev/null +++ b/templates/element_types.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Éléments de jeu — {{ game.name }}{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/Éléments de jeu +{% endblock %} +{% block content %} +

    Éléments de jeu

    +

    Un élément de jeu est un conteneur réutilisable.

    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + +

    Catalogue

    +{% if types %} +
    + {% for t in types %} +
    + +
    + ✏️ Modifier le contenu +
    + +
    +
    +
    + {% endfor %} +
    +{% else %} +

    Aucun élément de jeu défini pour l'instant.

    +{% endif %} +{% endblock %} diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html new file mode 100644 index 00000000..d8b255e8 --- /dev/null +++ b/templates/game_dashboard.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}{{ game.name }} — Forge Engine{% endblock %} +{% block breadcrumb %}{{ game.name }}{% endblock %} +{% block content %} +

    {{ game.name }}

    +

    Base de données : projects/{{ game.slug }}/game.db

    + + + + +
    ➕ Définir un nouvel objet
    +
    Comme une table de base de données : un nom, et des champs typés
    +
    + +

    Objets définis

    +{% if definitions %} +
    + {% for d in definitions %} + + {% endfor %} +
    +{% else %} +

    Aucun objet défini pour l'instant — un objet, c'est comme une table de base de données : un nom, et des champs typés.

    +{% endif %} + +
    +

    Paramètres du jeu

    +
    +
    + + +
    + +
    +
    + +
    +
    +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..fdd2506c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Forge Engine — Mes jeux{% endblock %} +{% block content %} +

    Mes jeux

    + +
    + +
    + + +
    +

    Crée un dossier avec index.html / index.css / index.js reliés entre eux, et une base de données dédiée à ce jeu.

    +
    + +

    Jeux existants

    +{% if games %} +
    + {% for g in games %} + + {% endfor %} +
    +{% else %} +

    Aucun jeu pour l'instant — crée le premier ci-dessus.

    +{% endif %} +{% endblock %} diff --git a/templates/object_edit.html b/templates/object_edit.html new file mode 100644 index 00000000..4d9ca16e --- /dev/null +++ b/templates/object_edit.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block title %}Modifier {{ definition.name }} — {{ game.name }}{% endblock %} +{% block content_class %}content-wide{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/ +{{ definition.name }} +/Modifier +{% endblock %} +{% block content %} +

    Modifier l'objet « {{ definition.name }} »

    +

    Table SQL : {{ definition.table_name }}

    + +
    +
    + + +
    + +
    + +
    + +
    +

    Ajouter un champ

    +
    + + + + + + + + + + + + + +
    + +
    +
    + +
    +

    Supprimer cet objet

    +
    + +
    +
    +
    + + +
    +

    Champs existants

    + {% if definition.fields %} + {% for f in definition.fields %} +
    +
    +
    +
    + + +
    +
    + + +
    +
    + {% if f.type == 'relation' %} + + + {% endif %} + {% if f.type in ('nombre_entier', 'nombre_decimal') %} +
    +
    + + +
    +
    + + +
    +
    +

    Laisser vide = pas de limite. S'applique à "Donner cette valeur", "Augmenter de..." et "Diminuer de..." dans le graphe de logique.

    + {% endif %} + + +
    + +
    +
    +
    + +
    +
    + {% endfor %} + {% else %} +

    Aucun champ pour l'instant.

    + {% endif %} +
    +
    + + +{% endblock %} diff --git a/templates/object_form.html b/templates/object_form.html new file mode 100644 index 00000000..0ac1fb50 --- /dev/null +++ b/templates/object_form.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Nouvel objet — {{ game.name }}{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/Nouvel objet +{% endblock %} +{% block content %} +

    Définir un nouvel objet

    + +
    + + + +

    Champs

    +
    + + +
    + +
    +
    + + + + +{% endblock %} diff --git a/templates/play.html b/templates/play.html new file mode 100644 index 00000000..02c7ff76 --- /dev/null +++ b/templates/play.html @@ -0,0 +1,608 @@ + + + + + +{{ game.name }} — Jouer + + + + + + +
    +
    +{% for s in screens_data %} +
    + {% for el in s.elements if not el.parent_id %} +
    +
    {{ el.rendered_html|safe }}
    +
    + {% endfor %} +
    +{% endfor %} +{% if not screens_data %} +
    Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.
    +{% endif %} +
    +
    + + + + diff --git a/templates/screen_edit.html b/templates/screen_edit.html new file mode 100644 index 00000000..27f3ad79 --- /dev/null +++ b/templates/screen_edit.html @@ -0,0 +1,1833 @@ +{% extends "base.html" %} +{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %} +{% block content_class %}content-builder{% endblock %} +{% block body_class %}builderBody{% endblock %} +{% block extra_head %} + + +{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/ +Écrans +/{{ screen.name }} +{% endblock %} +{% block content %} +
    +
    + + +
    + {% if not screen.is_template %} + ▶️ Jouer depuis le début + {% endif %} +
    + +{% if screen.is_template %} +

    + 🧩 Contenu d'un élément de jeu réutilisable. +

    +{% endif %} + +
    + {% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %} + +
    + {% if selected_id and el and el.widget in container_widgets %} +

    + ⚠️ Un conteneur est sélectionné. La grille ci-dessous pose l'élément directement sur l'écran, PAS à l'intérieur du conteneur — pour poser un élément dans le conteneur sélectionné, utilise plutôt « 📥 Ajouter DANS ce conteneur » dans le panneau de droite. +

    + {% endif %} +
    + Ajouter un élément sur l'écran +
    +
    + {% for wkey, w in widgets.items() %} +
    + + +
    + {% endfor %} +
    +
    +
    + + +
    + 🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }} +
    + {% if element_types %} +
    + {% for et in element_types %} +
    + + + +
    + {% endfor %} +
    + {% else %} +

    Pas encore d'élément de jeu personnalisé — crées-en un.

    + {% endif %} +
    +
    + +

    Éléments de cet écran

    + {% if top_level_elements %} +
    + {% for el in top_level_elements %} + +
    {{ widgets.get(el.widget, {}).get('icon', '❔') }} {{ el|elabel }}
    +
    {{ (el.content or '—')[:30] }}
    +
    + {% if el.widget in container_widgets and children_of.get(el.id) %} + + {% endif %} + {% endfor %} +
    + {% else %} +

    Aucun élément pour l'instant.

    + {% endif %} +
    + + +
    + {% set current_aspect = screen.preview_aspect or '9/16' %} +
    + Aperçu : + + + +
    +
    +
    + {% for el in top_level_elements %} +
    +
    {{ el.rendered_html|safe }}
    +
    +
    + {% endfor %} +
    +
    +

    Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.

    +
    + + +
    + {% if not selected_id %} +
    Sélectionne un élément (sur l'écran ou dans la liste) pour voir et modifier ses propriétés ici.
    + {% else %} + {% set el = elements|selectattr("id", "equalto", selected_id)|first %} + {% if el %} + {% set w = widgets.get(el.widget, {}) %} +
    +

    {{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} — {{ w.get('label', el.widget) }}{% endif %}

    +
    + +
    +
    +
    + + +
    +
    + + +
    + + {% if el.widget in container_widgets %} +
    + 📥 Ajouter DANS ce conteneur +
    + {% set children = children_of.get(el.id, []) %} + {% if children %} + + {% else %} +

    Aucun élément à l'intérieur pour l'instant — ajoute-en un ci-dessous.

    + {% endif %} +
    + {% for wkey, w in widgets.items() %} +
    + + +
    + {% endfor %} +
    +
    +
    + {% endif %} + +
    + Position & taille +
    +
    + + + + +
    +
    +
    + +
    + {% set ns = namespace(last_group=None, started=False) %} + {% for c in selected_controls %} + {% if c.group != ns.last_group %} + {% if ns.started %} +
    + + {% endif %} +
    + {{ c.group or 'Propriétés' }} +
    + {% set ns.last_group = c.group %} + {% set ns.started = True %} + {% endif %} +
    + + {% if c.type in ('text', 'url') %} + + {% if c.allow_upload %} +
    + + +
    +

    Colle une adresse (URL) ci-dessus, ou choisis un fichier sur ton ordinateur — il sera hébergé automatiquement avec ce jeu.

    + {% endif %} + {% elif c.type == 'textarea' %} + + {% elif c.type == 'color' %} + + {% elif c.type == 'slider' %} +
    + + {{ c.value }}{{ c.unit }} +
    + {% elif c.type == 'scale' %} +
    + + x{{ c.value }} +
    + {% elif c.type in ('checkbox', 'toggle_token') %} + + {% elif c.type == 'align' %} +
    + {% for val, icon in [('left','⬅️'),('center','⬛'),('right','➡️'),('justify','☰')] %} + + {% endfor %} +
    + {% elif c.type in ('select', 'preset') %} + + {% endif %} +
    + {% endfor %} + {% if ns.started %} +
    +
    + {% endif %} +
    Enregistré automatiquement
    + + + {% endif %} + {% endif %} +
    + + +{% if not screen.is_template %} + +
    + + +
    +
    + ▸ 🔀 Logique de la scène — nœuds et connexions +
    +
    +
    + + + + Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer. +
    + + + +
    + +
    +
    +
    +
    + + +
    + + +
    +
    + ▸ 🎬 Timeline d'animation +
    +
    +
    + + +
    + + + +
    +
    +
    +
    +
    +
    +{% endif %} + + +{% endblock %} diff --git a/templates/screens_list.html b/templates/screens_list.html new file mode 100644 index 00000000..a789fb9d --- /dev/null +++ b/templates/screens_list.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Écrans — {{ game.name }}{% endblock %} +{% block breadcrumb %} +{{ game.name }} +/Écrans +{% endblock %} +{% block content %} +

    Écrans du jeu

    + +
    + +
    + + +
    +
    + +{% if request.args.get('blocked_screen') %} +
    + ⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action. +
    +{% endif %} + +

    Enchaînement des écrans

    +{% if screen_list %} +
    + {% for s in screen_list %} + + {% endfor %} +
    +{% else %} +

    Aucun écran pour l'instant — crée le premier ci-dessus.

    +{% endif %} +{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..713b5c74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,79 @@ +""" +Fixtures pytest partagées. + +Les tests utilisent le VRAI client de test Flask (`app.test_client()`) et de +VRAIES bases SQLite créées sous projects/ (comme en production) — rien n'est +simulé. Chaque jeu de test est créé avec un nom préfixé "pytest_" et son +dossier est supprimé automatiquement à la fin du test (fixture `game`), +pour ne jamais laisser de données de test dans projects/. +""" +import os +import shutil +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest + +from core.flask_app import app as flask_app +from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname) +import routes # noqa: F401 - enregistre toutes les routes sur flask_app +import db + + +@pytest.fixture +def tmp_game_slug_cleanup(): + """Pour les tests qui appellent db.create_game(...) directement (sans + passer par une route Flask) : enregistre un slug à nettoyer à la fin du + test, quel que soit son résultat.""" + slugs = [] + + def register(slug): + slugs.append(slug) + return slug + + yield register + for slug in slugs: + game_dir = db.game_dir(slug) + if os.path.isdir(game_dir): + shutil.rmtree(game_dir) + + +@pytest.fixture +def client(): + flask_app.config["TESTING"] = True + with flask_app.test_client() as c: + yield c + + +@pytest.fixture +def game(client): + """Crée un jeu de test frais et le supprime après le test, quel que + soit son résultat (succès ou échec).""" + resp = client.post("/games/new", data={"name": "pytest_test_game"}, follow_redirects=False) + assert resp.status_code == 302 + slug = resp.headers["Location"].rstrip("/").split("/")[-1] + yield slug + game_dir = db.game_dir(slug) + if os.path.isdir(game_dir): + shutil.rmtree(game_dir) + + +@pytest.fixture +def mail_object(client, game): + """Un objet "Mail" avec deux champs texte (sujet, expediteur), pour les + tests de Répéteur / élément de jeu lié à un objet.""" + resp = client.post( + f"/game/{game}/objects/new", + data={ + "object_name": "Mail", + "field_name[]": ["sujet", "expediteur"], + "field_type[]": ["texte", "texte"], + "field_relation[]": ["", ""], + "field_required[]": ["0", "0"], + }, + follow_redirects=False, + ) + assert resp.status_code == 302 + definition_id = resp.headers["Location"].rstrip("/").split("/")[-1] + return {"slug": game, "definition_id": int(definition_id)} diff --git a/tests/test_animations.py b/tests/test_animations.py new file mode 100644 index 00000000..6fdde7ee --- /dev/null +++ b/tests/test_animations.py @@ -0,0 +1,115 @@ +"""Tests de la timeline d'animation : clips Animate.css ou personnalisés +(images-clés), posés sur la piste d'un élément, avec un instant de départ +et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran).""" +import json +import re + + +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): + 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 test_animate_css_clip_persists(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "titre") + resp = client.post( + f"/game/{game}/screens/{screen_id}/animations/clips/add", + data=json.dumps({ + "element_id": el_id, "kind": "animate_css", "animate_name": "bounce", + "start_time": "1.5", "duration": "2", "delay": "0", "easing": "ease", "iteration_count": "1", + }), + content_type="application/json", + ) + assert resp.status_code == 200 + clip = resp.get_json() + assert clip["kind"] == "animate_css" + assert clip["animate_name"] == "bounce" + assert clip["start_time"] == 1.5 + assert clip["duration"] == 2.0 + assert clip["element_id"] == el_id + + +def test_custom_keyframes_clip_persists(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "conteneur") + keyframes = [{"percent": 0, "props": {"opacity": "0"}}, {"percent": 100, "props": {"opacity": "1"}}] + resp = client.post( + f"/game/{game}/screens/{screen_id}/animations/clips/add", + data=json.dumps({ + "element_id": el_id, "kind": "custom", "custom_keyframes": keyframes, + "start_time": "0", "duration": "3", + }), + content_type="application/json", + ) + assert resp.status_code == 200 + clip = resp.get_json() + assert clip["kind"] == "custom" + assert json.loads(clip["custom_keyframes"]) == keyframes + + +def test_clip_update_changes_timing(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "bouton") + clip = client.post( + f"/game/{game}/screens/{screen_id}/animations/clips/add", + data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "fadeIn"}), + content_type="application/json", + ).get_json() + resp = client.post( + f"/game/{game}/animations/clips/{clip['id']}/update", + data=json.dumps({"start_time": "4", "duration": "0.5"}), + content_type="application/json", + ) + assert resp.status_code == 200 + updated = resp.get_json() + assert updated["start_time"] == 4.0 + assert updated["duration"] == 0.5 + + +def test_clip_delete_removes_it(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "bouton") + clip = client.post( + f"/game/{game}/screens/{screen_id}/animations/clips/add", + data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "tada"}), + content_type="application/json", + ).get_json() + resp = client.post(f"/game/{game}/animations/clips/{clip['id']}/delete") + assert resp.status_code == 200 + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert f'"id": {clip["id"]}' not in html + + +def test_screen_edit_exposes_timeline_panel_and_animate_css_cdn(client, game): + screen_id = _create_screen(client, game) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "animPanel" in html + assert "Timeline d'animation" in html + assert "animate.css" in html + + +def test_play_page_exposes_animation_runtime_and_cdn(client, game): + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "animate.css" in html + assert "runAnimationTimeline" in html + assert "injectCustomKeyframes" in html + + +def test_play_page_payload_includes_clip(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "titre") + client.post( + f"/game/{game}/screens/{screen_id}/animations/clips/add", + data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "pulse", "start_time": "2"}), + content_type="application/json", + ) + html = client.get(f"/game/{game}/play").data.decode() + assert '"animate_name": "pulse"' in html + assert '"start_time": 2.0' in html diff --git a/tests/test_bornage.py b/tests/test_bornage.py new file mode 100644 index 00000000..f057569c --- /dev/null +++ b/tests/test_bornage.py @@ -0,0 +1,110 @@ +"""Tests du bornage automatique des champs numériques (2.2).""" +import json +import re + + +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 _create_numeric_object(client, slug, field_name="reputation", min_v="0", max_v="100", start="50"): + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": "Partie", "field_name[]": [field_name], "field_type[]": ["nombre_entier"], + "field_relation[]": [""], "field_required[]": ["0"], + "field_min[]": [min_v], "field_max[]": [max_v], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + resp = client.post(f"/game/{slug}/objects/{def_id}/data/new", data={field_name: start}) + return def_id + + +def _get_row_value(client, slug, def_id, field_name): + import db + definition = db.get_definition(slug, def_id) + rows = db.list_rows(slug, definition) + return rows[0].get(db.slugify(field_name).replace("-", "_")) + + +def test_field_bounds_are_stored_at_creation(client, game): + def_id = _create_numeric_object(client, game, "reputation", "0", "100", "50") + import db + definition = db.get_definition(game, def_id) + field = definition["fields"][0] + assert field["min_value"] == 0 + assert field["max_value"] == 100 + + +def test_incrementer_clamps_to_max(client, game): + """Régression 2.2 : une action "Augmenter de..." ne doit jamais faire + dépasser la borne maxi réglée sur le champ, même si le montant ajouté + dépasse largement ce qu'il reste avant la limite.""" + def_id = _create_numeric_object(client, game, "reputation", "0", "100", "95") + row_value = _get_row_value(client, game, def_id, "reputation") + + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation", + "data_operation": "incrementer", "data_value": "50", + }), content_type="application/json") + node = resp.get_json() + + resp = client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data") + assert resp.get_json() == {"ok": True} + + new_value = _get_row_value(client, game, def_id, "reputation") + assert new_value == 100, f"la valeur aurait dû être bornée à 100, vaut {new_value}" + + +def test_decrementer_clamps_to_min(client, game): + """Régression 2.2 : symétrique pour "Diminuer de..." et la borne mini.""" + def_id = _create_numeric_object(client, game, "reputation", "0", "100", "5") + + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation", + "data_operation": "decrementer", "data_value": "50", + }), content_type="application/json") + node = resp.get_json() + + client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data") + new_value = _get_row_value(client, game, def_id, "reputation") + assert new_value == 0, f"la valeur aurait dû être bornée à 0, vaut {new_value}" + + +def test_definir_nombre_also_clamped(client, game): + """Régression 2.2 : "Donner cette valeur (nombre)" doit aussi être + bornée, pas seulement les incréments/décréments.""" + def_id = _create_numeric_object(client, game, "reputation", "0", "100", "50") + + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation", + "data_operation": "definir_nombre", "data_value": "9999", + }), content_type="application/json") + node = resp.get_json() + + client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data") + new_value = _get_row_value(client, game, def_id, "reputation") + assert new_value == 100 + + +def test_no_bounds_set_means_unlimited(client, game): + """Sans bornes réglées (champ laissé vide), le comportement doit rester + celui d'avant 2.2 : aucune limite appliquée.""" + def_id = _create_numeric_object(client, game, "reputation", "", "", "50") + + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation", + "data_operation": "incrementer", "data_value": "500", + }), content_type="application/json") + node = resp.get_json() + + client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data") + new_value = _get_row_value(client, game, def_id, "reputation") + assert new_value == 550 diff --git a/tests/test_conditions_combinees.py b/tests/test_conditions_combinees.py new file mode 100644 index 00000000..633b2c10 --- /dev/null +++ b/tests/test_conditions_combinees.py @@ -0,0 +1,108 @@ +"""Tests des conditions combinées ET/OU (2.4). + +Un nœud Condition peut porter plusieurs clauses (cond_clauses, JSON) combinées +par cond_combinator ('et'/'ou'), en plus de sa clause "historique" unique +(cond_definition_id/cond_field/...). Un nœud sans cond_clauses doit garder +EXACTEMENT son comportement d'avant (une seule comparaison) — c'est ce que +vérifient les tests de non-régression ci-dessous, en plus des tests dédiés +à la nouvelle fonctionnalité. +""" +import json +import re + + +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 _create_object_two_fields(client, slug): + """Crée un objet "Partie" avec deux champs numériques et une ligne.""" + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": "Partie", + "field_name[]": ["reputation", "niveau"], + "field_type[]": ["nombre_entier", "nombre_entier"], + "field_relation[]": ["", ""], + "field_required[]": ["0", "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={"reputation": "5", "niveau": "3"}) + return def_id + + +def test_legacy_single_clause_condition_still_works_without_cond_clauses(client, game): + """Non-régression : un nœud Condition créé SANS cond_clauses (comme avant + 2.4) doit toujours fonctionner à l'identique.""" + screen_id = _create_screen(client, game) + def_id = _create_object_two_fields(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "condition", + "cond_definition_id": def_id, "cond_row_id": 1, + "cond_field": "reputation", "cond_field_type": "nombre_entier", + "cond_operator": "superieur", "cond_value": "10", + }), + content_type="application/json", + ) + node = resp.get_json() + assert node["cond_clauses"] is None + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "evaluateConditionClause" in html + assert "cond_clauses" in html + + +def test_condition_node_persists_clauses_and_combinator(client, game): + screen_id = _create_screen(client, game) + def_id = _create_object_two_fields(client, game) + clauses = [ + {"definition_id": def_id, "row_id": 1, "field": "reputation", "field_type": "nombre_entier", "operator": "inferieur", "value": "20"}, + {"definition_id": def_id, "row_id": 1, "field": "niveau", "field_type": "nombre_entier", "operator": "superieur_egal", "value": "3"}, + ] + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "condition", + "cond_definition_id": def_id, "cond_row_id": 1, + "cond_field": "reputation", "cond_field_type": "nombre_entier", + "cond_operator": "inferieur", "cond_value": "20", + "cond_clauses": json.dumps(clauses), "cond_combinator": "ou", + }), + content_type="application/json", + ) + assert resp.status_code == 200 + node = resp.get_json() + assert node["cond_combinator"] == "ou" + stored_clauses = json.loads(node["cond_clauses"]) + assert len(stored_clauses) == 2 + assert stored_clauses[0]["field"] == "reputation" + assert stored_clauses[1]["field"] == "niveau" + + +def test_condition_combinator_defaults_to_et_when_absent(client, game): + """cond_combinator a une valeur par défaut 'et' en base même si on ne la + précise jamais (jeux migrés depuis avant 2.4).""" + screen_id = _create_screen(client, game) + def_id = _create_object_two_fields(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "condition", + "cond_definition_id": def_id, "cond_row_id": 1, + "cond_field": "reputation", "cond_field_type": "nombre_entier", + "cond_operator": "superieur", "cond_value": "10", + }), + content_type="application/json", + ) + node = resp.get_json() + assert node["cond_combinator"] == "et" + + +def test_play_page_exposes_combinator_logic(client, game): + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "cond_combinator" in html + assert "'ou'" in html diff --git a/tests/test_confort.py b/tests/test_confort.py new file mode 100644 index 00000000..9216e8df --- /dev/null +++ b/tests/test_confort.py @@ -0,0 +1,179 @@ +"""Tests des points "Confort" (section 3 de la fiche de cadrage) : +3.1 interactions au survol, 3.2 séquences temporisées, 3.3 surbrillance +générique dynamique, 3.4 overlay/modale réutilisable, 3.5 verrouillage +d'un élément après décision.""" +import json +import re + + +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): + 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)) + + +# ---------- 3.1 — Interactions au survol ---------- + +def test_hover_text_control_saved_and_rendered(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "titre") + client.post(f"/game/{game}/elements/{el_id}/save", data={ + "ctrl_content": "Mathilde Dubois", "ctrl_survol_texte": "mathilde.d@forgebase.fr", + }) + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert 'data-hover-text="mathilde.d@forgebase.fr"' in html + + +def test_hover_text_absent_by_default_no_regression(client, game): + """Sans réglage de survol, aucun attribut data-hover-text ne doit + apparaître — aucune régression sur les éléments existants.""" + screen_id = _create_screen(client, game) + _add_element(client, game, screen_id, "titre") + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + # (le mot "data-hover-text" apparaît dans un commentaire JS du moteur + # lui-même — on vérifie donc l'absence de l'ATTRIBUT réellement posé sur + # un élément, pas la simple présence de la chaîne dans la page) + assert 'data-hover-text="' not in html + + +def test_play_page_exposes_hover_binding_runtime(client, game): + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "bindHoverTexts" in html + + +# ---------- 3.2 — Séquences temporisées ---------- + +def test_attendre_action_node_persists_delay(client, game): + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({"node_type": "action", "action_type": "attendre", "data_value": "5"}), + content_type="application/json", + ) + assert resp.status_code == 200 + node = resp.get_json() + assert node["action_type"] == "attendre" + assert node["data_value"] == "5" + + +def test_play_page_exposes_wait_action_runtime(client, game): + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "'attendre'" in html + assert "setTimeout" in html + + +# ---------- 3.3 — Surbrillance générique dynamique ---------- + +def test_surbrillance_action_node_persists(client, game): + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "bouton") + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "action", "action_type": "modifier_element", + "target_element_id": el_id, "element_property": "surbrillance", "element_value": "on", + }), + content_type="application/json", + ) + assert resp.status_code == 200 + node = resp.get_json() + assert node["element_property"] == "surbrillance" + assert node["element_value"] == "on" + + +def test_play_page_exposes_highlight_runtime_and_css(client, game): + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "forgeHighlight" in html + assert "'surbrillance'" in html + + +# ---------- 3.4 — Overlay / modale réutilisable ---------- + +def test_overlay_widget_renders_fullscreen_fixed_box(client, game): + screen_id = _create_screen(client, game) + overlay_id = _add_element(client, game, screen_id, "superposition") + client.post(f"/game/{game}/elements/{overlay_id}/save", data={"ctrl_couleur_boite": "#222222", "ctrl_arrondi": "20"}) + client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "titre"}) + + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + idx = html.find(f'data-element-id="{overlay_id}"') + assert idx != -1 + # Le tag qui porte data-element-id est celui rendu par _render_overlay : + # on regarde tout son contenu de balise ouvrante (jusqu'au ">" suivant) + # ainsi que ce qui suit immédiatement (la boîte centrée à l'intérieur). + tag_start = html.rfind(" deux data-row-id DIFFÉRENTS. + row_ids = re.findall(r'data-row-id="(\d+)"', html) + assert len(row_ids) == 2 + assert len(set(row_ids)) == 2 + + +def test_ouvrir_ligne_action_node_persists_target_screen(client, game): + """Régression 1.1 : la nouvelle action "Ouvrir la ligne de Répéteur + cliquée" doit accepter un écran cible comme l'action "aller_a", pour + que l'écran de détail (ex: lecture d'un mail) puisse être choisi dans + l'éditeur de logique.""" + import json + + inbox_id = _create_screen(client, game, "Boîte de réception") + detail_id = _create_screen(client, game, "Détail du mail") + resp = client.post(f"/game/{game}/screens/{inbox_id}/elements/add", data={"widget": "repeteur_donnees"}) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + resp = client.post( + f"/game/{game}/screens/{inbox_id}/flow/nodes/add", + data=json.dumps({"node_type": "trigger", "trigger_element_id": rep_id, "trigger_event": "clic"}), + content_type="application/json", + ) + trigger_node = resp.get_json() + + resp = client.post( + f"/game/{game}/screens/{inbox_id}/flow/nodes/add", + data=json.dumps({"node_type": "action", "action_type": "ouvrir_ligne", "target_screen_id": detail_id}), + content_type="application/json", + ) + assert resp.status_code == 200 + action_node = resp.get_json() + assert action_node["action_type"] == "ouvrir_ligne" + assert action_node["target_screen_id"] == detail_id + + resp = client.post( + f"/game/{game}/screens/{inbox_id}/flow/edges/add", + data=json.dumps({"from_node_id": trigger_node["id"], "from_port": "out", "to_node_id": action_node["id"]}), + content_type="application/json", + ) + assert resp.status_code == 200 + + +def test_play_page_exposes_row_bindings_helpers(client, game): + """Le runtime jouable doit embarquer la logique côté client capable de + résoudre les {{champ}} de la ligne ouverte (voir README, section + "Cliquer une ligne de Répéteur") — vérifie que le JS livré au joueur + contient bien les briques attendues, sans dépendre d'un navigateur.""" + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "applyOpenRowBindings" in html + assert "lastClickedRowId" in html + assert "'ouvrir_ligne'" in html + + +def test_repeater_filter_literal_value(client, game, mail_object): + """Régression 1.2 : un filtre réglé sur le Répéteur (champ + opérateur + + valeur fixe) ne doit garder que les lignes qui correspondent, sur un + écran UNIQUE (pas un écran par niveau) — voir claude/forge-engine-lacunes-boitemail.md.""" + slug, definition_id = mail_object["slug"], mail_object["definition_id"] + client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"}) + client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"}) + + screen_id = _create_screen(client, slug) + resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{slug}/elements/{rep_id}/save", data={ + "ctrl_definition_id": str(definition_id), "ctrl_content": "{{sujet}}", + "ctrl_filtre_champ": "sujet", "ctrl_filtre_operateur": "egal", "ctrl_filtre_valeur": "Sujet 2", + }) + + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode() + rows = re.findall(r'
    ]*>(.*?)
    ', html) + assert len(rows) == 1, f"le filtre aurait dû ne garder qu'UNE ligne, en a laissé {len(rows)}" + assert "Sujet 2" in rows[0] + + +def test_repeater_filter_reference_to_another_object(client, game, mail_object): + """Régression 1.2 : le filtre doit aussi savoir comparer à une valeur qui + change en cours de partie via "{{NomObjet.champ}}" (ex: {{Partie.niveau}}), + pas seulement à une valeur fixe — c'est ce qui permet à UN SEUL écran de + s'adapter à la progression sans dupliquer un écran par niveau.""" + slug, definition_id = mail_object["slug"], mail_object["definition_id"] + client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"}) + client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"}) + + # Objet "Partie" à une seule ligne, jouant le rôle d'état global de partie. + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": "Partie", "field_name[]": ["sujet_actif"], "field_type[]": ["texte"], + "field_relation[]": [""], "field_required[]": ["0"], + }, follow_redirects=False) + partie_def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + client.post(f"/game/{slug}/objects/{partie_def_id}/data/new", data={"sujet_actif": "Sujet 1"}) + + screen_id = _create_screen(client, slug) + resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{slug}/elements/{rep_id}/save", data={ + "ctrl_definition_id": str(definition_id), "ctrl_content": "{{sujet}}", + "ctrl_filtre_champ": "sujet", "ctrl_filtre_operateur": "egal", + "ctrl_filtre_valeur": "{{Partie.sujet_actif}}", + }) + + html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode() + rows = re.findall(r'
    ]*>(.*?)
    ', html) + assert len(rows) == 1, f"le filtre aurait dû ne garder qu'UNE ligne, en a laissé {len(rows)}" + assert "Sujet 1" in rows[0] + + +def test_affichage_trigger_does_not_require_element(client, game): + """Régression 1.2 : le nouveau déclencheur "affichage" doit pouvoir être + créé SANS élément cible (il concerne l'écran entier), contrairement au + déclencheur "clic" — voir screens/flow/constants.py.""" + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({"node_type": "trigger", "trigger_event": "affichage"}), + content_type="application/json", + ) + assert resp.status_code == 200 + node = resp.get_json() + assert node["trigger_event"] == "affichage" + assert node["trigger_element_id"] is None + + +def test_play_page_exposes_screen_show_trigger_helper(client, game): + """Le runtime jouable doit exécuter les nœuds "affichage" à chaque fois + qu'un écran est montré (premier affichage, retour en arrière, données + modifiées) — voir README, section correspondante.""" + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "runScreenShowTriggers" in html + assert "'affichage'" in html diff --git a/tests/test_jauge.py b/tests/test_jauge.py new file mode 100644 index 00000000..a3dbc05d --- /dev/null +++ b/tests/test_jauge.py @@ -0,0 +1,107 @@ +"""Tests du widget Jauge (2.1 : binding live d'une donnée vers un style).""" +import re + + +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 _create_partie_object(client, slug, field_name="reputation", value="50"): + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": "Partie", "field_name[]": [field_name], "field_type[]": ["texte"], + "field_relation[]": [""], "field_required[]": ["0"], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + client.post(f"/game/{slug}/objects/{def_id}/data/new", data={field_name: value}) + return def_id + + +def test_gauge_width_reflects_field_value_without_extra_action(client, game): + """Régression 2.1 : la largeur de la jauge doit refléter automatiquement + la valeur du champ suivi, sans qu'aucune action "Modifier un élément" + n'ait été posée dans le graphe de logique.""" + def_id = _create_partie_object(client, game, "reputation", "50") + screen_id = _create_screen(client, game) + + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100", + }) + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "width:50.0%" in html + + +def test_gauge_updates_after_data_change_with_no_extra_wiring(client, game): + """Régression 2.1 : quand la donnée change (ex: une action "Modifier une + donnée" exécutée en jeu), la jauge doit refléter la NOUVELLE valeur au + prochain rendu — sans binding manuel à recâbler.""" + def_id = _create_partie_object(client, game, "reputation", "50") + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100", + }) + + # Simule une action "Modifier une donnée" qui change la valeur en base. + client.post(f"/game/{game}/objects/{def_id}/data/new", data={"reputation": "90"}) + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "width:90.0%" in html + + +def test_gauge_color_gradient_shifts_between_low_and_high(client, game): + """Régression 2.1 : la couleur de remplissage doit se rapprocher de la + couleur "maxi" quand la valeur est haute, pas rester figée.""" + def_id = _create_partie_object(client, game, "reputation", "100") + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100", + "ctrl_couleur_basse": "#ff0000", "ctrl_couleur_haute": "#00ff00", + }) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "background-color:#00ff00" in html # valeur au max -> couleur "haute" pure + + +def test_gauge_value_clamped_visually_when_out_of_range(client, game): + """Une valeur au-delà du maxi réglé (avant que 2.2 n'empêche ça à la + source) ne doit pas casser l'affichage — la jauge doit rester à 100%, + pas déborder.""" + def_id = _create_partie_object(client, game, "reputation", "500") + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100", + }) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "width:100.0%" in html + + +# ---------- Régression : clics multipliés après un rafraîchissement de +# données (bouton +10/-10 relié à une jauge) ---------- + +def test_play_page_click_binding_guards_against_duplicate_listeners(client, game): + """Bug remonté par l'utilisateur : après plusieurs clics sur un bouton + relié à une action "Modifier une donnée" (ex: +10 points), l'effet + devenait instable (parfois -20, 0, +100 au lieu de ±10 par clic). + + Cause : bindClicks() est rappelée après CHAQUE refreshRuntimeData() (donc + après chaque donnée modifiée), et un bouton ordinaire (ni Répéteur, ni + Jauge) garde le MÊME nœud DOM d'un rafraîchissement à l'autre — sans + garde-fou, addEventListener('click') s'empile à chaque rafraîchissement, + et un seul clic finit par déclencher l'action plusieurs fois d'affilée. + + On vérifie ici la présence du garde-fou (clickBound) qui empêche ce + ré-attachement — un vrai test de non-régression comportementale (clics + répétés via un navigateur réel) a été exécuté manuellement pour valider + le correctif : 5 clics sur +10 -> exactement +50, jamais plus.""" + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "clickBound" in html + assert "if (elDiv.dataset.clickBound) return;" in html diff --git a/tests/test_onglets.py b/tests/test_onglets.py new file mode 100644 index 00000000..3091fd8c --- /dev/null +++ b/tests/test_onglets.py @@ -0,0 +1,42 @@ +"""Tests du panneau à onglets / visibilité mutuellement exclusive (2.3).""" +import json +import re + + +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 test_activer_onglet_action_node_persists_target_element(client, game): + """Régression 2.3 : le nouveau type d'action doit accepter un élément + cible comme "modifier_element", sans propriété/valeur associées (tout + est déduit automatiquement au clic côté jouable : montrer CET élément, + masquer ses frères).""" + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}) + parent_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + child_ids = [] + for _ in range(3): + resp = client.post(f"/game/{game}/elements/{parent_id}/children/add", data={"widget": "titre"}) + child_ids.append(int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))) + + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({"node_type": "action", "action_type": "activer_onglet", "target_element_id": child_ids[1]}), + content_type="application/json", + ) + assert resp.status_code == 200 + node = resp.get_json() + assert node["action_type"] == "activer_onglet" + assert node["target_element_id"] == child_ids[1] + + +def test_play_page_exposes_activer_onglet_runtime(client, game): + """Le runtime jouable doit savoir exécuter "activer_onglet" (montrer un + élément, masquer ses frères) — vérifie que le JS livré au joueur + contient bien cette logique, sans dépendre d'un navigateur.""" + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + assert "'activer_onglet'" in html + assert "parentKey" in html diff --git a/tests/test_screen_layout_fixes.py b/tests/test_screen_layout_fixes.py new file mode 100644 index 00000000..d9fda794 --- /dev/null +++ b/tests/test_screen_layout_fixes.py @@ -0,0 +1,140 @@ +"""Tests des corrections suite au retour utilisateur du 21/08 : +- le format d'aperçu (Portrait/Paysage/Carré) d'un écran est mémorisé + côté serveur, au lieu de repartir sur Portrait à chaque fois que le + panneau est redemandé au serveur (ajout/suppression d'élément, + changement de sélection) ; +- l'échelle (transform:scale) d'un élément posé directement sur un écran + vit sur un cadre intermédiaire (.canvasElementInner / .playElementInner), + à la taille exacte du cadre de positionnement/animation (.canvasElement / + .playElement) mais distinct de lui — ni sur la balise intérieure générée + par render_element_html (sinon l'effet restait visuellement "coincé" à + l'intérieur d'un cadre immobile), ni sur le cadre extérieur lui-même + (sinon ce "transform" entrait en collision avec celui posé par Animate.css + pendant une animation, qui l'écrasait) ; +- cette même zone de jeu garde la proportion de l'écran en mode Jouer + (voir #playFrame dans play.html), pour que les positions/tailles en % + ne se déforment pas selon la fenêtre du joueur.""" +import re + + +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): + 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 test_screen_defaults_to_portrait(client, game): + screen_id = _create_screen(client, game) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert 'aspect-ratio:9/16;' in html + assert 'aspectBtn active" data-aspect="9/16"' in html + + +def test_aspect_choice_persists_across_reload(client, game): + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/aspect", + data='{"aspect": "16/9"}', + content_type="application/json", + ) + assert resp.status_code == 200 + + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert 'aspect-ratio:16/9;' in html + assert 'aspectBtn active" data-aspect="16/9"' in html + + +def test_aspect_choice_survives_element_add_and_delete(client, game): + """Le vrai bug rapporté : l'écran repartait sur Portrait dès qu'on + posait ou supprimait un élément, car ces deux actions redemandent le + panneau au serveur.""" + screen_id = _create_screen(client, game) + client.post( + f"/game/{game}/screens/{screen_id}/aspect", + data='{"aspect": "1/1"}', + content_type="application/json", + ) + el_id = _add_element(client, game, screen_id, "bouton") + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert 'aspect-ratio:1/1;' in html + + client.post(f"/game/{game}/elements/{el_id}/delete", follow_redirects=False) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert 'aspect-ratio:1/1;' in html + + +def test_unknown_aspect_value_is_ignored(client, game): + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/aspect", + data='{"aspect": "not-a-ratio"}', + content_type="application/json", + ) + assert resp.status_code == 200 + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert 'aspect-ratio:9/16;' in html + + +def test_scale_lives_on_outer_frame_for_top_level_element(client, game): + """Un conteneur posé directement sur l'écran : l'échelle doit être sur + le cadre intermédiaire .canvasElementInner, pas sur le cadre extérieur + .canvasElement (id="el-", cible d'Animate.css — un "transform" posé + là entrerait en collision avec celui d'une animation en cours) ni sur la + balise intérieure générée par render_element_html (sinon l'effet restait + visuellement clippé à l'intérieur d'un cadre qui, lui, ne grossissait + pas).""" + screen_id = _create_screen(client, game) + el_id = _add_element(client, game, screen_id, "conteneur") + client.post( + f"/game/{game}/elements/{el_id}/save", + data={"ctrl_scale": "1.5"}, + ) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + # Le cadre extérieur (id="el-", cible Animate.css) ne porte PAS le transform... + outer_match = re.search(rf'id="el-{el_id}"[^>]*style="([^"]*)"', html) + assert outer_match, "cadre .canvasElement introuvable" + assert "transform" not in outer_match.group(1) + # ...le cadre intermédiaire .canvasElementInner le porte... + inner_html = html.split(f'id="el-{el_id}"', 1)[1] + canvas_inner_match = re.search(r'class="canvasElementInner" style="([^"]*)"', inner_html) + assert canvas_inner_match, "cadre .canvasElementInner introuvable" + assert "transform:scale(1.5)" in canvas_inner_match.group(1) + # ...et la balise intérieure (générée par render_element_html) ne le + # répète pas une seconde fois. + content_style_match = re.search(r'class="canvasElementInner"[^>]*>\s*
    copie indépendante ---- + real_screen_id = _create_screen(client, slug, "Accueil") + resp = client.post( + f"/game/{slug}/screens/{real_screen_id}/elements/add", + data={"widget": "__catalogue__", "element_type_id": str(element_type_id)}, + follow_redirects=False, + ) + assert resp.status_code == 302 + html = client.get(f"/game/{slug}/screens/{real_screen_id}/edit").data.decode() + assert "{{sujet}}" in html # non résolu hors répéteur : limitation connue et documentée + + # ---- Case B : modèle de ligne d'un Répéteur -> résolu en direct par ligne ---- + resp = client.post(f"/game/{slug}/screens/{real_screen_id}/elements/add", data={"widget": "repeteur_donnees"}) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post( + f"/game/{slug}/elements/{rep_id}/save", + data={"ctrl_definition_id": str(definition_id), "ctrl_template_type_id": str(element_type_id)}, + ) + html = client.get(f"/game/{slug}/screens/{real_screen_id}/edit").data.decode() + assert "Sujet 1" in html + assert "Sujet 2" in html + + +def test_unrelated_property_save_does_not_bake_in_default_background(client, game): + """Régression : changer un réglage (ex. la disposition interne) via le + panneau de propriétés ne doit PAS écrire la couleur de fond par défaut + du conteneur dans son style — cette valeur n'est qu'un aperçu affiché + dans le sélecteur de couleur tant qu'elle n'a pas été choisie + explicitement. Avant le correctif, comme tous les réglages du widget + sont envoyés dans UN SEUL formulaire, sauvegarder n'importe lequel + d'entre eux figeait aussi la couleur par défaut (#1f2430).""" + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False + ) + container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + + html_before = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode() + assert "background-color" not in html_before + + # On ne touche qu'au préréglage de disposition, rien d'autre. + client.post( + f"/game/{game}/elements/{container_id}/save", + data={"ctrl_disposition_interne": "ligne"}, + follow_redirects=False, + ) + html_after = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode() + assert "flex-direction:row" in html_after + assert "background-color" not in html_after, ( + "La couleur de fond par défaut a été écrite alors qu'elle n'a jamais été choisie." + ) + + # Si on choisit vraiment une couleur, elle doit en revanche être conservée. + client.post( + f"/game/{game}/elements/{container_id}/save", + data={"ctrl_disposition_interne": "ligne", "ctrl_background": "#ff0000"}, + follow_redirects=False, + ) + html_custom = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode() + assert "background-color:#ff0000" in html_custom + + +def test_image_gets_object_fit_cover_by_default(client, game): + """Régression : une image fraîchement posée doit démarrer avec + object-fit:cover (pour se recadrer proprement une fois redimensionnée) + même si le panneau de propriétés n'a jamais été enregistré — ce réglage + ne doit pas dépendre du même mécanisme de sauvegarde que la couleur de + fond (voir test ci-dessus), sous peine de régresser vers le + comportement par défaut du navigateur (image étirée/déformée).""" + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "image"}, follow_redirects=False + ) + assert resp.status_code == 302 + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "object-fit:cover" in html + + +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() + 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)) + + assert client.get(f"/game/{game}/screens/{template_screen_id}/edit").status_code == 200 + client.post(f"/game/{game}/element-types/{element_type_id}/delete") + assert client.get(f"/game/{game}/screens/{template_screen_id}/edit").status_code == 404