first commit
Build and deploy / build-and-push (push) Successful in 17s
Build and deploy / deploy (push) Successful in 10s

This commit is contained in:
william
2026-08-21 16:23:49 +02:00
committed by william
commit 3f4ebc4527
248 changed files with 10180 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.gitea
.gitignore
.pytest_cache
__pycache__
*.pyc
.venv
venv
tests
projects
README.md
.env
.env.local
docker-compose.prod.yml
+14
View File
@@ -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
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+748
View File
@@ -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.
+32
View File
@@ -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)
View File
+9
View File
@@ -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)
+10
View File
@@ -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
+64
View File
@@ -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",
]
+20
View File
@@ -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
+16
View File
@@ -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"},
}
+7
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Définitions d'objet (le "schéma" créé à la souris) et leurs champs."""
+43
View File
@@ -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()
+57
View File
@@ -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
+16
View File
@@ -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]
+12
View File
@@ -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()
+28
View File
@@ -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()
+18
View File
@@ -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
+8
View File
@@ -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]
+8
View File
@@ -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()
+48
View File
@@ -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()
+7
View File
@@ -0,0 +1,7 @@
import os
from .constants import PROJECTS_DIR
def game_dir(slug):
return os.path.join(PROJECTS_DIR, slug)
+1
View File
@@ -0,0 +1 @@
"""Cycle de vie d'un jeu (création, méta, suppression)."""
+77
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
import shutil
from ..game_dir import game_dir
def delete_game(slug):
shutil.rmtree(game_dir(slug))
+8
View File
@@ -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}
+20
View File
@@ -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
+8
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Lignes de données (CRUD) pour les objets définis dans un jeu."""
+8
View File
@@ -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()
+10
View File
@@ -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
+14
View File
@@ -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()
+8
View File
@@ -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]
+19
View File
@@ -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
+26
View File
@@ -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
+26
View File
@@ -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
+16
View File
@@ -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()
+15
View File
@@ -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()
+8
View File
@@ -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"
+6
View File
@@ -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("-", "_")
+11
View File
@@ -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:
View File
+5
View File
@@ -0,0 +1,5 @@
import db
def _colname(field_name):
return db.slugify(field_name).replace("-", "_")
+37
View File
@@ -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 ""
+3
View File
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
python_files = test_*.py
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.3.3
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
gunicorn==26.1.0
+1
View File
@@ -0,0 +1 @@
Flask==3.0.3
+1
View File
@@ -0,0 +1 @@
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations
+5
View File
@@ -0,0 +1,5 @@
from . import (
animation_clip_add,
animation_clip_update,
animation_clip_delete,
)
+19
View File
@@ -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))
+1
View File
@@ -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))
+16
View File
@@ -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))
+25
View File
@@ -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,
)
+8
View File
@@ -0,0 +1,8 @@
from . import (
element_add,
element_add_child,
element_geometry,
element_save,
element_delete,
element_rename,
)
+20
View File
@@ -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))
+25
View File
@@ -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))
+22
View File
@@ -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))
+20
View File
@@ -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})
+14
View File
@@ -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))
+18
View File
@@ -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))
+9
View File
@@ -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,
)
+17
View File
@@ -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})
+11
View File
@@ -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})
+21
View File
@@ -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))
+11
View File
@@ -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})
+12
View File
@@ -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})
+18
View File
@@ -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})
+13
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
from . import index, games_new, game_dashboard, game_rename, game_delete
+12
View File
@@ -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)
+11
View File
@@ -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"))
+13
View File
@@ -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))
+14
View File
@@ -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))
+10
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
from . import action_add, action_delete, action_run_data
+35
View File
@@ -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))
+15
View File
@@ -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"]))
+17
View File
@@ -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})
+14
View File
@@ -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,
)
+20
View File
@@ -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))
+17
View File
@@ -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)
+32
View File
@@ -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,
)
+7
View File
@@ -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)
+18
View File
@@ -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))
+28
View File
@@ -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,
)
+20
View File
@@ -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))
+11
View File
@@ -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))
+20
View File
@@ -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))
+27
View File
@@ -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,
)
+33
View File
@@ -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,
)
+20
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from . import game_play

Some files were not shown because too many files have changed in this diff Show More