first commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.gitignore
|
||||||
|
.pytest_cache
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
tests
|
||||||
|
projects
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
docker-compose.prod.yml
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
'
|
||||||
+23
@@ -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/
|
||||||
+15
@@ -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"]
|
||||||
@@ -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/<nom-du-jeu>/index.html` + `index.css` + `index.js` (reliés
|
||||||
|
entre eux), et `projects/<nom-du-jeu>/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_<nom>`), 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-<numéro>`) 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 `<input type="color">` 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 `<title>`, 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.
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Définitions d'objet (le "schéma" créé à la souris) et leurs champs."""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from .constants import PROJECTS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def game_dir(slug):
|
||||||
|
return os.path.join(PROJECTS_DIR, slug)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cycle de vie d'un jeu (création, méta, suppression)."""
|
||||||
@@ -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}</title>
|
||||||
|
<link rel="stylesheet" href="index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{name}</h1>
|
||||||
|
<p>Squelette généré par le moteur Forge — à remplir au fur et à mesure
|
||||||
|
des fonctionnalités du jeu.</p>
|
||||||
|
<script src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import shutil
|
||||||
|
|
||||||
|
from ..game_dir import game_dir
|
||||||
|
|
||||||
|
|
||||||
|
def delete_game(slug):
|
||||||
|
shutil.rmtree(game_dir(slug))
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Lignes de données (CRUD) pour les objets définis dans un jeu."""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -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 <select> de relation."""
|
||||||
|
related = get_definition(slug, related_definition_id)
|
||||||
|
conn = connect(slug)
|
||||||
|
rows = conn.execute(f"SELECT * FROM {related['table_name']} ORDER BY id").fetchall()
|
||||||
|
conn.close()
|
||||||
|
display_field = related["fields"][0]["name"] if related["fields"] else "id"
|
||||||
|
display_col = slugify(display_field).replace("-", "_")
|
||||||
|
options = []
|
||||||
|
for r in rows:
|
||||||
|
r = dict(r)
|
||||||
|
label = r.get(display_col, f"#{r['id']}")
|
||||||
|
options.append({"id": r["id"], "label": f"{label} (#{r['id']})"})
|
||||||
|
return options
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from ..slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
|
def row_columns_and_values(definition, form_data):
|
||||||
|
"""Traduit les données d'un formulaire généré en (colonnes, valeurs)
|
||||||
|
prêtes pour un INSERT ou un UPDATE — factorisé pour que les deux
|
||||||
|
partagent exactement la même logique de conversion de type."""
|
||||||
|
cols, values = [], []
|
||||||
|
for f in definition["fields"]:
|
||||||
|
fname = slugify(f["name"]).replace("-", "_")
|
||||||
|
if f["type"] == "relation":
|
||||||
|
col = f"{fname}_id"
|
||||||
|
raw = form_data.get(col, "")
|
||||||
|
values.append(int(raw) if raw else None)
|
||||||
|
elif f["type"] == "booleen":
|
||||||
|
values.append(1 if form_data.get(fname) == "on" else 0)
|
||||||
|
col = fname
|
||||||
|
elif f["type"] in ("nombre_entier", "nombre_decimal"):
|
||||||
|
raw = form_data.get(fname, "")
|
||||||
|
values.append((int(raw) if f["type"] == "nombre_entier" else float(raw)) if raw != "" else None)
|
||||||
|
col = fname
|
||||||
|
else:
|
||||||
|
values.append(form_data.get(fname, "") or None)
|
||||||
|
col = fname
|
||||||
|
cols.append(col)
|
||||||
|
return cols, values
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from ..connection import connect
|
||||||
|
from ..slugify import slugify
|
||||||
|
from ..definitions.definitions_referencing import definitions_referencing
|
||||||
|
from ..definitions.get_definition import get_definition
|
||||||
|
|
||||||
|
|
||||||
|
def rows_referencing(slug, definition_id, row_id):
|
||||||
|
"""Pour l'écran de suppression d'une ligne : liste, pour chaque objet
|
||||||
|
qui a une relation vers l'objet de cette ligne, combien de ses lignes
|
||||||
|
pointent vers CETTE ligne précise — pour avertir avant une suppression
|
||||||
|
qui laisserait des relations orphelines (aucune suppression en cascade
|
||||||
|
n'est faite automatiquement, par sécurité)."""
|
||||||
|
refs = []
|
||||||
|
for d in definitions_referencing(slug, definition_id):
|
||||||
|
full = get_definition(slug, d["id"])
|
||||||
|
for f in full["fields"]:
|
||||||
|
if f["type"] == "relation" and f["relation_definition_id"] == definition_id:
|
||||||
|
col = slugify(f["name"]).replace("-", "_") + "_id"
|
||||||
|
conn = connect(slug)
|
||||||
|
count = conn.execute(
|
||||||
|
f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {col} = ?", (row_id,)
|
||||||
|
).fetchone()["c"]
|
||||||
|
conn.close()
|
||||||
|
if count:
|
||||||
|
refs.append({"definition_name": full["name"], "field_name": f["name"], "count": count})
|
||||||
|
return refs
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from ..connection import connect
|
||||||
|
from .row_columns_and_values import row_columns_and_values
|
||||||
|
|
||||||
|
|
||||||
|
def update_row(slug, definition, row_id, form_data):
|
||||||
|
"""CRUD — Update : ré-enregistre une ligne existante avec les nouvelles
|
||||||
|
valeurs saisies dans le même formulaire généré que pour la création."""
|
||||||
|
cols, values = row_columns_and_values(definition, form_data)
|
||||||
|
conn = connect(slug)
|
||||||
|
set_clause = ", ".join(f"{c} = ?" for c in cols)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ?",
|
||||||
|
values + [row_id],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from ..connection import connect
|
||||||
|
from ..slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
|
def update_row_field(slug, definition, row_id, field_def, new_value):
|
||||||
|
"""Met à jour UNE SEULE colonne d'une ligne existante — utilisé par les
|
||||||
|
actions "Modifier une donnée" du mode jouable, qui ne doivent jamais
|
||||||
|
écraser les autres champs de la ligne (contrairement à update_row, qui
|
||||||
|
attend un formulaire complet)."""
|
||||||
|
fname = slugify(field_def["name"]).replace("-", "_")
|
||||||
|
col = f"{fname}_id" if field_def["type"] == "relation" else fname
|
||||||
|
conn = connect(slug)
|
||||||
|
conn.execute(f"UPDATE {definition['table_name']} SET {col} = ? WHERE id = ?", (new_value, row_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name):
|
||||||
|
s = name.strip().lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||||
|
s = re.sub(r"-+", "-", s).strip("-")
|
||||||
|
return s or "jeu"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from .slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
|
def table_name_for(object_name):
|
||||||
|
"""Nom de la vraie table SQL pour un objet défini par l'utilisateur."""
|
||||||
|
return "obj_" + slugify(object_name).replace("-", "_")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
forge-engine:
|
||||||
|
image: ${REGISTRY_IMAGE:?REGISTRY_IMAGE is required}:${IMAGE_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-5050}:5050"
|
||||||
|
volumes:
|
||||||
|
- forge_projects:/app/projects
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
forge_projects:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
def _colname(field_name):
|
||||||
|
return db.slugify(field_name).replace("-", "_")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
def _element_style(el):
|
||||||
|
"""Construit le style inline de POSITIONNEMENT (position/taille en % +
|
||||||
|
empilement) du cadre qui porte un élément posé sur un écran. L'apparence
|
||||||
|
de l'élément lui-même (couleurs, police, etc.) vit dans le HTML généré
|
||||||
|
par screens.render_element_html — ce filtre ne gère que le placement,
|
||||||
|
partagé par l'éditeur et le mode jouable.
|
||||||
|
|
||||||
|
Ce cadre (.canvasElement / .playElement) est aussi la cible des
|
||||||
|
animations Animate.css (data-anim-target, voir screen_edit.html et
|
||||||
|
play.html) : il ne porte donc PAS le "transform" (échelle/rotation) de
|
||||||
|
l'élément, pour ne jamais entrer en collision avec le "transform" que
|
||||||
|
Animate.css pilote lui-même pendant qu'une animation joue (Animate.css
|
||||||
|
pose "animation-fill-mode: both", qui remplace entièrement la valeur de
|
||||||
|
la propriété — un "transform" inline posé ici serait écrasé pendant, et
|
||||||
|
pour une animation en boucle infinie, en permanence). Voir
|
||||||
|
_element_transform_style, qui porte ce "transform" sur un cadre séparé
|
||||||
|
(.canvasElementInner / .playElementInner), à la taille exacte de celui-ci
|
||||||
|
(donc visuellement identique)."""
|
||||||
|
return "; ".join([
|
||||||
|
"position:absolute",
|
||||||
|
f"left:{el['x']}%", f"top:{el['y']}%",
|
||||||
|
f"width:{el['width']}%", f"height:{el['height']}%",
|
||||||
|
f"z-index:{el['z_index']}",
|
||||||
|
]) + ";"
|
||||||
|
|
||||||
|
|
||||||
|
def _element_transform_style(el):
|
||||||
|
"""Style inline de l'échelle/rotation d'un élément posé directement sur
|
||||||
|
un écran — voir _element_style pour pourquoi ce "transform" est séparé
|
||||||
|
du cadre de positionnement/animation. Ce cadre intermédiaire
|
||||||
|
(.canvasElementInner / .playElementInner) fait exactement la même taille
|
||||||
|
que le cadre extérieur (width/height:100%, voir static/style.css et
|
||||||
|
play.html) : y poser le "transform" produit donc le même rendu visuel
|
||||||
|
que s'il était sur le cadre extérieur, sans le risque de collision avec
|
||||||
|
Animate.css."""
|
||||||
|
transform = (el.get("style") or {}).get("transform")
|
||||||
|
return f"transform:{transform};" if transform else ""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.3
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
gunicorn==26.1.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flask==3.0.3
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from . import (
|
||||||
|
animation_clip_add,
|
||||||
|
animation_clip_update,
|
||||||
|
animation_clip_delete,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/animations/clips/add", methods=["POST"])
|
||||||
|
def animation_clip_add(slug, screen_id):
|
||||||
|
"""Ajoute un clip d'animation (Animate.css ou personnalisé) sur la piste
|
||||||
|
d'un élément, dans le panneau timeline — appelé en AJAX, comme l'ajout
|
||||||
|
d'un nœud de logique."""
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
element_id = payload.get("element_id")
|
||||||
|
if not element_id:
|
||||||
|
return jsonify({"error": "élément manquant"}), 400
|
||||||
|
fields = {k: v for k, v in payload.items() if k != "element_id"}
|
||||||
|
clip_id = screens.add_animation_clip(slug, screen_id, int(element_id), **fields)
|
||||||
|
return jsonify(screens.get_animation_clip(slug, clip_id))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/animations/clips/<int:clip_id>/delete", methods=["POST"])
|
||||||
|
def animation_clip_delete(slug, clip_id):
|
||||||
|
screens.delete_animation_clip(slug, clip_id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/animations/clips/<int:clip_id>/update", methods=["POST"])
|
||||||
|
def animation_clip_update(slug, clip_id):
|
||||||
|
"""Modifie un clip existant — glisser/redimensionner sur la timeline
|
||||||
|
n'envoie que start_time/duration, le formulaire d'édition peut envoyer
|
||||||
|
n'importe quel autre réglage (type, animation Animate.css, images-clés
|
||||||
|
personnalisées, délai, vitesse, répétitions)."""
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
screens.update_animation_clip(slug, clip_id, **payload)
|
||||||
|
return jsonify(screens.get_animation_clip(slug, clip_id))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import element_types, element_type_edit, element_type_delete
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/element-types/<int:element_type_id>/delete", methods=["POST"])
|
||||||
|
def element_type_delete(slug, element_type_id):
|
||||||
|
if screens.element_type_usage_count(slug, element_type_id) == 0:
|
||||||
|
screens.delete_element_type(slug, element_type_id)
|
||||||
|
return redirect(url_for("element_types", slug=slug))
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/element-types/<int:element_type_id>/edit", methods=["POST"])
|
||||||
|
def element_type_edit(slug, element_type_id):
|
||||||
|
"""Renomme un élément de jeu et/ou change l'objet auquel il est lié —
|
||||||
|
son contenu (l'arbre d'éléments) se modifie séparément, via le lien
|
||||||
|
"✏️ Modifier le contenu" qui ouvre l'écran-modèle dans l'éditeur normal."""
|
||||||
|
name = request.form.get("name", "").strip() or None
|
||||||
|
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||||
|
screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id)
|
||||||
|
return redirect(url_for("element_types", slug=slug))
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from flask import render_template, request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
|
||||||
|
def element_types(slug):
|
||||||
|
game = db.game_meta(slug)
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
icon = request.form.get("icon", "").strip() or "🧩"
|
||||||
|
bound_definition_id = request.form.get("bound_definition_id") or None
|
||||||
|
if name:
|
||||||
|
screens.create_element_type(slug, name, icon, bound_definition_id)
|
||||||
|
return redirect(url_for("element_types", slug=slug))
|
||||||
|
types = screens.list_element_types(slug)
|
||||||
|
usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in types}
|
||||||
|
definitions = db.list_definitions(slug)
|
||||||
|
return render_template(
|
||||||
|
"element_types.html", game=game, types=types, usage=usage, widgets=screens.WIDGETS,
|
||||||
|
definitions=definitions,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from . import (
|
||||||
|
element_add,
|
||||||
|
element_add_child,
|
||||||
|
element_geometry,
|
||||||
|
element_save,
|
||||||
|
element_delete,
|
||||||
|
element_rename,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/elements/add", methods=["POST"])
|
||||||
|
def element_add(slug, screen_id):
|
||||||
|
widget = request.form.get("widget", "conteneur")
|
||||||
|
from_catalogue = widget == "__catalogue__"
|
||||||
|
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
||||||
|
if from_catalogue and not element_type_id:
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||||
|
if from_catalogue:
|
||||||
|
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
||||||
|
et = types.get(int(element_type_id))
|
||||||
|
widget = et["default_widget"] if et else "conteneur"
|
||||||
|
new_id = screens.add_element(slug, screen_id, widget, element_type_id)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from flask import request, redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:parent_id>/children/add", methods=["POST"])
|
||||||
|
def element_add_child(slug, parent_id):
|
||||||
|
"""Ajoute un élément PHYSIQUEMENT à l'intérieur d'un conteneur, d'un
|
||||||
|
répéteur de données ou d'un groupe de champs déjà posé sur l'écran."""
|
||||||
|
parent = screens.get_element(slug, parent_id)
|
||||||
|
if not parent:
|
||||||
|
abort(404)
|
||||||
|
widget = request.form.get("widget", "conteneur")
|
||||||
|
from_catalogue = widget == "__catalogue__"
|
||||||
|
element_type_id = request.form.get("element_type_id") or None if from_catalogue else None
|
||||||
|
if from_catalogue and element_type_id:
|
||||||
|
types = {t["id"]: t for t in screens.list_element_types(slug)}
|
||||||
|
et = types.get(int(element_type_id))
|
||||||
|
widget = et["default_widget"] if et else "conteneur"
|
||||||
|
elif from_catalogue:
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=parent_id))
|
||||||
|
new_id = screens.add_element(slug, parent["screen_id"], widget, element_type_id, parent_id=parent_id)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=parent["screen_id"], selected=new_id))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from flask import redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/delete", methods=["POST"])
|
||||||
|
def element_delete(slug, element_id):
|
||||||
|
element = screens.get_element(slug, element_id)
|
||||||
|
if not element:
|
||||||
|
abort(404)
|
||||||
|
screen_id = element["screen_id"]
|
||||||
|
parent_id = element.get("parent_id")
|
||||||
|
screens.delete_element(slug, element_id)
|
||||||
|
# Resélectionne le conteneur parent (s'il y en a un) après suppression,
|
||||||
|
# plutôt que de laisser le panneau de droite vide : sinon, supprimer un
|
||||||
|
# élément fait brusquement "sauter" l'éditeur vers un état sans rien de
|
||||||
|
# sélectionné.
|
||||||
|
if parent_id:
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=parent_id))
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/geometry", methods=["POST"])
|
||||||
|
def element_geometry(slug, element_id):
|
||||||
|
"""Appelé en AJAX pendant/à la fin d'un glisser-déposer, d'un
|
||||||
|
redimensionnement, ou d'une modification des champs Position & taille —
|
||||||
|
ne touche qu'à la position/taille, aucun rechargement de page nécessaire
|
||||||
|
pour que ça reste fluide."""
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
screens.update_element_geometry(
|
||||||
|
slug, element_id,
|
||||||
|
x=float(data["x"]), y=float(data["y"]),
|
||||||
|
width=float(data["width"]), height=float(data["height"]),
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import request, redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/rename", methods=["POST"])
|
||||||
|
def element_rename(slug, element_id):
|
||||||
|
element = screens.get_element(slug, element_id)
|
||||||
|
if not element:
|
||||||
|
abort(404)
|
||||||
|
screens.rename_element(slug, element_id, request.form.get("name", ""))
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import request, redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/save", methods=["POST"])
|
||||||
|
def element_save(slug, element_id):
|
||||||
|
"""Un seul formulaire pour tout le panneau de propriétés : un champ par
|
||||||
|
réglage du widget (texte, case à cocher, curseur, couleur...) — c'est
|
||||||
|
screens.save_element_controls qui sait vers quel attribut ou quelle
|
||||||
|
propriété CSS chaque réglage doit être traduit."""
|
||||||
|
element = screens.get_element(slug, element_id)
|
||||||
|
if not element:
|
||||||
|
abort(404)
|
||||||
|
screens.save_element_controls(slug, element_id, request.form)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from . import (
|
||||||
|
flow_node_add,
|
||||||
|
flow_node_move,
|
||||||
|
flow_node_delete,
|
||||||
|
flow_edge_add,
|
||||||
|
flow_edge_delete,
|
||||||
|
flow_node_run_data,
|
||||||
|
runtime_payload,
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/flow/edges/add", methods=["POST"])
|
||||||
|
def flow_edge_add(slug, screen_id):
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
try:
|
||||||
|
from_node_id = int(payload["from_node_id"])
|
||||||
|
to_node_id = int(payload["to_node_id"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return jsonify({"error": "nœuds invalides"}), 400
|
||||||
|
edge_id = screens.add_flow_edge(slug, screen_id, from_node_id, payload.get("from_port", "out"), to_node_id)
|
||||||
|
return jsonify({"id": edge_id})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/edges/<int:edge_id>/delete", methods=["POST"])
|
||||||
|
def flow_edge_delete(slug, edge_id):
|
||||||
|
screens.delete_flow_edge(slug, edge_id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/screens/<int:screen_id>/flow/nodes/add", methods=["POST"])
|
||||||
|
def flow_node_add(slug, screen_id):
|
||||||
|
"""Ajoute un nœud (Déclencheur / Condition / Action) au graphe de
|
||||||
|
logique de cet écran — appelé en AJAX depuis le panneau "Logique de la
|
||||||
|
scène", pour que poser un nœud ne recharge jamais toute la page."""
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
node_type = payload.get("node_type")
|
||||||
|
if node_type not in ("trigger", "condition", "action"):
|
||||||
|
return jsonify({"error": "type de nœud invalide"}), 400
|
||||||
|
x = float(payload.get("x", 40))
|
||||||
|
y = float(payload.get("y", 40))
|
||||||
|
fields = {k: v for k, v in payload.items() if k not in ("node_type", "x", "y")}
|
||||||
|
node_id = screens.add_flow_node(slug, screen_id, node_type, x=x, y=y, **fields)
|
||||||
|
return jsonify(screens.get_flow_node(slug, node_id))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/delete", methods=["POST"])
|
||||||
|
def flow_node_delete(slug, node_id):
|
||||||
|
screens.delete_flow_node(slug, node_id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/move", methods=["POST"])
|
||||||
|
def flow_node_move(slug, node_id):
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
screens.move_flow_node(slug, node_id, float(payload.get("x", 0)), float(payload.get("y", 0)))
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
|
||||||
|
def flow_node_run_data(slug, node_id):
|
||||||
|
"""Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud
|
||||||
|
Action de type "Modifier une donnée d'un objet" — identique au
|
||||||
|
fonctionnement de l'ancienne action_run_data, mais lit sa config depuis
|
||||||
|
un nœud du graphe de logique plutôt que depuis la table _actions."""
|
||||||
|
node = screens.get_flow_node(slug, node_id)
|
||||||
|
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
|
||||||
|
return jsonify({"ok": False}), 400
|
||||||
|
ok = screens.apply_data_action(slug, node)
|
||||||
|
return jsonify({"ok": ok})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/runtime-payload")
|
||||||
|
def runtime_payload(slug):
|
||||||
|
"""Utilisé par le mode jouable pour se remettre à jour après une action
|
||||||
|
qui a modifié une donnée d'objet (ex: un Répéteur affichant cet objet
|
||||||
|
doit refléter la nouvelle valeur sans recharger toute la page)."""
|
||||||
|
return jsonify(screens.full_game_payload(slug))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import index, games_new, game_dashboard, game_rename, game_delete
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>")
|
||||||
|
def game_dashboard(slug):
|
||||||
|
game = db.game_meta(slug)
|
||||||
|
definitions = db.list_definitions(slug)
|
||||||
|
return render_template("game_dashboard.html", game=game, definitions=definitions)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/delete", methods=["POST"])
|
||||||
|
def game_delete(slug):
|
||||||
|
db.delete_game(slug)
|
||||||
|
return redirect(url_for("index"))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/rename", methods=["POST"])
|
||||||
|
def game_rename(slug):
|
||||||
|
new_name = request.form.get("name", "").strip()
|
||||||
|
if new_name:
|
||||||
|
db.update_game_name(slug, new_name)
|
||||||
|
return redirect(url_for("game_dashboard", slug=slug))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/games/new", methods=["POST"])
|
||||||
|
def games_new():
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
slug = db.create_game(name)
|
||||||
|
return redirect(url_for("game_dashboard", slug=slug))
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html", games=db.list_games())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import action_add, action_delete, action_run_data
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from flask import request, redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/actions/add", methods=["POST"])
|
||||||
|
def action_add(slug, element_id):
|
||||||
|
element = screens.get_element(slug, element_id)
|
||||||
|
if not element:
|
||||||
|
abort(404)
|
||||||
|
trigger_event = screens._trigger_for(element["widget"], element["attributes"])
|
||||||
|
action_type = request.form.get("action_type", "rien")
|
||||||
|
|
||||||
|
def _int_or_none(key):
|
||||||
|
raw = request.form.get(key)
|
||||||
|
try:
|
||||||
|
return int(raw) if raw not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
screens.add_action(
|
||||||
|
slug, element_id, trigger_event, action_type,
|
||||||
|
target_screen_id=_int_or_none("target_screen_id"),
|
||||||
|
target_element_id=_int_or_none("target_element_id"),
|
||||||
|
element_property=request.form.get("element_property") or None,
|
||||||
|
element_value=request.form.get("element_value") or None,
|
||||||
|
target_definition_id=_int_or_none("target_definition_id"),
|
||||||
|
target_row_id=_int_or_none("target_row_id"),
|
||||||
|
target_field=request.form.get("target_field") or None,
|
||||||
|
data_operation=request.form.get("data_operation") or None,
|
||||||
|
data_value=request.form.get("data_value") or None,
|
||||||
|
)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element_id))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from flask import redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/actions/<int:action_id>/delete", methods=["POST"])
|
||||||
|
def action_delete(slug, action_id):
|
||||||
|
action = screens.get_action(slug, action_id)
|
||||||
|
if not action:
|
||||||
|
abort(404)
|
||||||
|
element = screens.get_element(slug, action["element_id"])
|
||||||
|
screens.delete_action(slug, action_id)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=element["id"]))
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/actions/<int:action_id>/run-data", methods=["POST"])
|
||||||
|
def action_run_data(slug, action_id):
|
||||||
|
"""Exécuté côté serveur au moment du clic, en mode jouable, pour une
|
||||||
|
action "Modifier une donnée d'un objet" — les actions "Modifier un
|
||||||
|
élément" restent, elles, purement côté client (juste du style CSS)."""
|
||||||
|
action = screens.get_action(slug, action_id)
|
||||||
|
if not action or action.get("action_type") != "modifier_donnee":
|
||||||
|
return jsonify({"ok": False}), 400
|
||||||
|
ok = screens.apply_data_action(slug, action)
|
||||||
|
return jsonify({"ok": ok})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from . import (
|
||||||
|
parse_field_rows,
|
||||||
|
object_new,
|
||||||
|
object_view,
|
||||||
|
object_edit,
|
||||||
|
object_field_add,
|
||||||
|
object_field_edit,
|
||||||
|
object_field_delete,
|
||||||
|
object_delete,
|
||||||
|
data_form,
|
||||||
|
data_new,
|
||||||
|
data_edit,
|
||||||
|
data_delete,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import redirect, url_for, abort
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/delete", methods=["POST"])
|
||||||
|
def data_delete(slug, definition_id, row_id):
|
||||||
|
definition = db.get_definition(slug, definition_id)
|
||||||
|
if not definition:
|
||||||
|
abort(404)
|
||||||
|
referencing = db.rows_referencing(slug, definition_id, row_id)
|
||||||
|
if referencing:
|
||||||
|
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
|
||||||
|
# une relation. On revient à la liste, qui explique pourquoi via un
|
||||||
|
# paramètre de requête simple plutôt qu'une session/flash message.
|
||||||
|
return redirect(url_for("object_view", slug=slug, definition_id=definition_id, blocked_row=row_id))
|
||||||
|
db.delete_row(slug, definition, row_id)
|
||||||
|
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import abort
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
from .data_form import _data_form
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/data/<int:row_id>/edit", methods=["GET", "POST"])
|
||||||
|
def data_edit(slug, definition_id, row_id):
|
||||||
|
definition = db.get_definition(slug, definition_id)
|
||||||
|
if not definition:
|
||||||
|
abort(404)
|
||||||
|
row = db.get_row(slug, definition, row_id)
|
||||||
|
if not row:
|
||||||
|
abort(404)
|
||||||
|
return _data_form(slug, definition_id, row=row)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from flask import render_template, request, redirect, url_for, abort
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
def _data_form(slug, definition_id, row):
|
||||||
|
game = db.game_meta(slug)
|
||||||
|
definition = db.get_definition(slug, definition_id)
|
||||||
|
if not definition:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Pour chaque champ de type relation, on prépare les options du <select>
|
||||||
|
# à partir des lignes déjà enregistrées de l'objet lié.
|
||||||
|
relation_options = {}
|
||||||
|
for f in definition["fields"]:
|
||||||
|
if f["type"] == "relation":
|
||||||
|
relation_options[f["id"]] = db.relation_options(slug, f["relation_definition_id"])
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if row is None:
|
||||||
|
db.insert_row(slug, definition, request.form)
|
||||||
|
else:
|
||||||
|
db.update_row(slug, definition, row["id"], request.form)
|
||||||
|
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"data_form.html",
|
||||||
|
game=game,
|
||||||
|
definition=definition,
|
||||||
|
relation_options=relation_options,
|
||||||
|
row=row,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from core.flask_app import app
|
||||||
|
from .data_form import _data_form
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/data/new", methods=["GET", "POST"])
|
||||||
|
def data_new(slug, definition_id):
|
||||||
|
return _data_form(slug, definition_id, row=None)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/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))
|
||||||
@@ -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/<slug>/objects/<int:definition_id>/edit", methods=["GET", "POST"])
|
||||||
|
def object_edit(slug, definition_id):
|
||||||
|
game = db.game_meta(slug)
|
||||||
|
definition = db.get_definition(slug, definition_id)
|
||||||
|
if not definition:
|
||||||
|
abort(404)
|
||||||
|
existing_definitions = [d for d in db.list_definitions(slug) if d["id"] != definition_id]
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/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))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/delete", methods=["POST"])
|
||||||
|
def object_field_delete(slug, definition_id, field_id):
|
||||||
|
db.delete_field(slug, definition_id, field_id)
|
||||||
|
return redirect(url_for("object_edit", slug=slug, definition_id=definition_id))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>/fields/<int:field_id>/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))
|
||||||
@@ -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/<slug>/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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from flask import render_template, request, abort
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/objects/<int:definition_id>")
|
||||||
|
def object_view(slug, definition_id):
|
||||||
|
game = db.game_meta(slug)
|
||||||
|
definition = db.get_definition(slug, definition_id)
|
||||||
|
if not definition:
|
||||||
|
abort(404)
|
||||||
|
rows = db.list_rows(slug, definition)
|
||||||
|
|
||||||
|
# Pour l'affichage : traduit chaque id de relation en un libellé lisible
|
||||||
|
# (ex. "Salle serveur (#1)") au lieu d'un identifiant numérique brut.
|
||||||
|
relation_labels = {}
|
||||||
|
for f in definition["fields"]:
|
||||||
|
if f["type"] == "relation":
|
||||||
|
options = db.relation_options(slug, f["relation_definition_id"])
|
||||||
|
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
|
||||||
|
|
||||||
|
referenced_by = db.definitions_referencing(slug, definition_id)
|
||||||
|
|
||||||
|
blocked_row_id = request.args.get("blocked_row", type=int)
|
||||||
|
blocked_row_refs = db.rows_referencing(slug, definition_id, blocked_row_id) if blocked_row_id else None
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"data_list.html", game=game, definition=definition, rows=rows,
|
||||||
|
relation_labels=relation_labels, referenced_by=referenced_by,
|
||||||
|
blocked_row_id=blocked_row_id, blocked_row_refs=blocked_row_refs,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import game_play
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user