Compare commits

...
24 Commits
Author SHA1 Message Date
w-vandal b1c995ab46 Merge pull request 'Dev' (#11) from dev into main
Build and deploy / test-python (push) Successful in 7m0s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / build-and-push (push) Successful in 5m40s
Build and deploy / deploy (push) Successful in 55s
Reviewed-on: #11
2026-09-04 19:13:40 +00:00
william 50835a18e2 Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 1m0s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Le document de cadrage produit cible des formateurs non techniques créant
des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de
logique en nœuds, timeline d'animation, définitions d'objets/relations,
templates réutilisables) est une complexité hors cible que l'effort
d'ingénierie récent avait déjà abandonnée au profit du jeu_2d.

- Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire
  Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only)
- Suppression en bloc des modules exclusifs au document : routes/elements,
  routes/element_types, routes/objects, routes/legacy_actions,
  screens/elements, screens/element_types, screens/widgets,
  screens/legacy_actions, le rendu render_element_html.py et son cluster,
  templates/screen_edit.html, templates/game_dashboard.html,
  flow-editor.js/tabs-and-blocks.js/animation-timeline.js
- Dashboard toujours simplifié (un seul mode possible désormais)
- Tests document-only supprimés, tests de logique partagée (flow,
  événements personnalisés, animations) retargetés sur des écrans jeu_2d
- Aucune régression jeu_2d : 299 tests passent

Carte d'onboarding retravaillée : argumentaire RH non technique (liste à
coche, badge "Compatible LMS"), taille et interaction de retournement
ajustées.
2026-09-04 20:56:32 +02:00
williamandClaude Sonnet 5 00f7191a8b Corrige les animations figées et le reporting SCORM dans le paquet exporté
Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Deux bugs distincts détectés dans le paquet Web/SCORM :
- L'animation de marche du personnage dépend entièrement des touches
  clavier maintenues (heldKeys), qui ne sont alimentées que par des
  écouteurs keydown/keyup sur window. Un LMS comme SCORM Cloud charge
  index.html dans une iframe qui ne reçoit pas le focus clavier par
  défaut : aucune touche n'atteignait jamais window, donc le personnage
  ne bougeait/animait jamais. On force désormais le focus au chargement
  et on le reprend au premier clic dans le jeu.
- scorm-api.js (qui pousse score/statut au LMS) était bien copié dans
  le zip mais jamais référencé par un <script> dans index.html, car
  build_scorm_package.py ne passait pas scorm_api_wrapper_url au
  template — le reporting SCORM (Completion/Success/Score) ne
  fonctionnait donc jamais depuis un export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:23:17 +02:00
williamandClaude Sonnet 5 413685e871 Relie le score/statut des quêtes aux indicateurs SCORM
Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Les quêtes/quiz mettaient à jour l'état local (statut, score) sans
jamais toucher gameData.scoring, que scorm-api.js interroge déjà pour
reporter Completion/Success/Score au LMS. Ajoute forgeSyncQuizScoreToScorm,
forgeSyncQuestStartedToScorm et forgeSyncAllQuestsCompletionToScorm,
appelées lors d'un bon score, de l'acceptation d'une quête et de la
complétion de toutes les quêtes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:56:37 +02:00
williamandClaude Sonnet 5 55c6bb330b Contrôle direct de la taille de la caméra (Largeur × Hauteur, toujours modifiable)
Build and deploy / test-python (push) Failing after 1m13s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Remplace le bouton conditionnel "Agrandir la caméra à la zone visible"
(n'apparaissait que si un fond dépassait déjà la scène) par deux champs
"Caméra (px)" TOUJOURS visibles, pré-remplis avec scene_width/
scene_height actuels — demande explicite : "l'écran de l'éditeur a une
taille fixe, la caméra a la même taille, c'est tout", un réglage direct
plutôt qu'une suggestion automatique liée à la taille d'un fond.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:28:03 +02:00
williamandClaude Sonnet 5 b9d3c46087 Bouton "📐 Agrandir la caméra à la zone visible"
Build and deploy / test-python (push) Failing after 1m23s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Fait de tout le "monde" affiché par l'éditeur (la taille réelle d'un
"fond" plus grand que la scène nominale, voir routes/scenes/
scene_edit_view.py::world_width/world_height) la scène/caméra ELLE-MÊME
— jusqu'ici scene_width/scene_height n'étaient fixées qu'à la création
de l'écran, jamais modifiables ensuite. Le bouton n'apparaît que quand
un fond dépasse encore la scène nominale (même condition que le repère
"🎥 Champ de la caméra"), affiche la taille cible, recharge la page une
fois appliqué. La caméra ne recadre alors plus rien en jeu (monde ==
scène == viewport, voir personnage-controller.js::forgeUpdateSceneCamera).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:07:24 +02:00
williamandClaude Sonnet 5 14ec76dddd Éditeur : le canevas affiche tout le monde (fond agrandi), pas que la scène
Build and deploy / test-python (push) Failing after 1m20s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Suite au fix du glisser figé à (0,0) : le canevas de l'éditeur restait
malgré tout limité à la scène nominale (960×540 par ex.) — la partie
d'un "fond" plus grand qui dépasse restait invisible/injoignable à la
souris. Le canevas affiche désormais tout le "monde" (max de la scène
et de l'étendue de chaque "fond", voir routes/scenes/scene_edit_view.py),
comme la caméra en jeu le fait déjà (personnage-controller.js).

Nouveau repère visuel "🎥 Champ de la caméra" : un cadre en pointillés
marquant où s'arrête la scène nominale à l'intérieur de ce monde agrandi
(le reste est assombri), pour que l'auteur voie clairement ce que le
joueur verra réellement à l'écran une fois le monde plus grand que le
viewport.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:55:09 +02:00
williamandClaude Sonnet 5 6b4846a646 Fix vrai bug : un objet plus grand que la scène (fond/caméra) restait figé à (0,0)
Build and deploy / test-python (push) Failing after 1m22s
Build and deploy / test-js (push) Successful in 56s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Confirmé par l'utilisateur : lié à l'introduction du fond/caméra (voir
add_scene_object.py — un "fond" est posé à sa taille RÉELLE, souvent
bien plus grande que la scène, exprès, pour que la caméra le suive en
défilant sur un monde plus grand que le viewport).

Le glisser en position utilisait Math.max(0, Math.min(SCENE_WIDTH -
width, ...)) — cette formule suppose SCENE_WIDTH - width POSITIF (objet
plus petit que la scène). Pour un objet plus GRAND (ex. un fond de
1920px sur une scène de 960px), cette différence est NÉGATIVE, et
Math.max(0, négatif) ramenait TOUJOURS la position à 0 quel que soit le
glisser — l'objet restait donc figé, impossible à repositionner.

Le redimensionnement plafonnait aussi la largeur/hauteur à "ce qui
reste dans la scène depuis son coin" (SCENE_WIDTH - left) — empêchant
justement de rendre un objet plus grand que la scène par glisser (un
fond ne pouvait être agrandi qu'en le recréant via la galerie).

Les deux bornes sont corrigées pour fonctionner quel que soit lequel
(scène ou objet) est le plus grand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:43:08 +02:00
williamandClaude Sonnet 5 7c9b2c9b25 Fix "impossible d'aller plus loin vers la droite" — défilement automatique du canevas
Build and deploy / test-python (push) Failing after 1m13s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Cause confirmée (tous les objets bloquaient au même endroit, pas
spécifique à un widget) : une scène à taille fixe (960×540 par ex.)
peut être plus LARGE que la zone visible de .canvasFrame (overflow:auto)
selon la largeur de fenêtre — sa partie droite/basse défile alors HORS
de vue, et le curseur atteignait le bord de cette zone VISIBLE bien
avant celui de la scène elle-même : la souris ne pouvait tout simplement
plus bouger physiquement plus loin, sans aucun rapport avec la limite
réelle (960px) de la scène.

onSceneObjectMouseDown/onSceneObjectResizeMouseDown font maintenant
défiler .canvasFrame automatiquement quand le curseur approche un bord
pendant un glisser/redimensionnement — et continuent de déplacer
l'objet même si la souris reste immobile près du bord (mirror du
comportement standard d'un glisser-déposer dans une zone scrollable),
grâce à une position ACCUMULÉE plutôt que recalculée depuis le point de
départ fixe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:27:51 +02:00
williamandClaude Sonnet 5 046c38b4dc Annule la réservation d'espace des panneaux, corrige le vrai bug (drag invisible sous eux)
Build and deploy / test-python (push) Failing after 1m22s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Retour en arrière sur le commit précédent : les panneaux flottants
doivent RESTER en position:fixed par-dessus le canevas pour maximiser
l'espace utile (remarque explicite) — leur réserver une marge
permanente allait à l'encontre de ce principe et ne réglait rien.

Vrai bug identifié : un objet glissé (déplacé ou redimensionné) sous
l'un de ces panneaux (z-index:60) disparaissait littéralement à
l'écran PENDANT le geste — toujours déplacé/enregistré correctement en
dessous, juste invisible, donnant l'impression qu'on ne pouvait pas y
déposer d'objet. Élevé au-dessus des panneaux (z-index très haut) le
temps du glisser/redimensionnement SEULEMENT, restauré ensuite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:18:42 +02:00
williamandClaude Sonnet 5 d705f58c4a Fix accès au canevas masqué + quiz : header quête, réponse fausse n'avance jamais bloquée, animé
Build and deploy / test-python (push) Failing after 1m36s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
- Bug corrigé : les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" sont en
  position:fixed (hors du flux flex de .builder3) — ils flottaient
  PAR-DESSUS le canevas sans jamais réduire sa largeur, rendant sa
  partie droite/gauche inaccessible au clic/glisser tant qu'un panneau
  restait ouvert. .builderCanvasArea réserve maintenant leur largeur
  (marge) dès qu'un panneau est ouvert (:has()).

- Boîte à quiz : header = "Quête : <titre>" (au lieu du texte de la
  question), corps = la question ET ses choix ensemble.

- Mauvaise réponse : ne bloque plus JAMAIS la progression (bug signalé :
  "je suis obligé de bien répondre sinon j'avance pas") — surligne la
  bonne réponse en vert (le choix cliqué en rouge s'il était faux) puis
  avance automatiquement après un court délai, sans jamais octroyer de
  points. Un second clic pendant la révélation est ignoré.

- Animations : la boîte à quiz rejoue une entrée (pop-in) à CHAQUE
  nouvelle question, la bonne réponse pulse en vert, une mauvaise
  réponse "secoue" en rouge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:08:59 +02:00
williamandClaude Sonnet 5 b5546df044 Quiz jouable : budget de récompense, boîte à quiz, widget score
Build and deploy / test-python (push) Failing after 1m34s
Build and deploy / test-js (push) Successful in 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme
  des points des "❓ Question" ne peut plus dépasser recompense_score
  (routes/quests/quest_dialogues.py à l'enregistrement des dialogues,
  quest_update.py si on abaisse la récompense sous ce qui est déjà
  réparti) — message d'erreur explicite (400) dans les deux sens,
  affiché via alert() côté éditeur (quest-editor.js), jamais enregistré
  silencieusement dans un état incohérent.
- Nombre de choix par question plafonné à 4 (au lieu de 8).

- Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte
  de dialogue" — screens/rendering/dialogue_box_style.py, même panneau
  "🎨 Style") :
  - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une
    question se résout au clic sur un choix, pas de "Suivant").
  - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible
    une fois posé (contrairement aux boîtes de dialogue/quiz, masquées
    par défaut).

- Moteur d'exécution (static/js/play/dialogue-box-controller.js,
  refonte) : une conversation de quête alterne maintenant répliques
  (boîte de dialogue) et questions (boîte à quiz) selon le type de
  chaque ligne. Bonne réponse -> crédite le score (compteur runtime
  dédié, jamais lié au score du moteur document) et avance ; mauvaise
  réponse -> rien, la question reste affichée pour réessayer ; aucune
  boîte à quiz posée -> la question est ignorée plutôt que de bloquer
  la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait
  des questions, toutes répondues) fait passer la quête "terminee" (en
  mémoire seulement, comme le reste de ce système — voir accepter/
  refuser une offre de quête) : le joueur peut alors quitter la scène
  normalement, ces widgets n'étant jamais des obstacles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:42:42 +02:00
williamandClaude Sonnet 5 e972acf9e6 Quêtes : bulle "❓ Question" à choix multiples, à côté de "+ Réplique"
Build and deploy / test-python (push) Successful in 6m25s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Nouveau type de ligne dans un dialogue de quête (voir
db/quests/sanitize_quest_dialogues.py) : une bulle JAUNE, dans la même
chaîne verticale que les répliques (même alignement, reliée par un
trait) — header : nombre de choix (2 à 8) + récompense (score, montant
en points) ; body : la question, ses choix et un bouton radio pour
désigner la bonne réponse. Aucune limite au nombre de questions par
colonne, comme pour une réplique.

Les lignes de dialogue portent maintenant explicitement
{"type": "dialogue", ...} (au lieu d'un objet sans type) pour
distinguer les deux formes — migration de forme, tests mis à jour.

Portée de ce commit : l'ÉDITEUR (créer/modifier une question). Le jeu
lui-même (afficher la question au joueur, vérifier la réponse,
attribuer la récompense) reste à construire — le widget "💬 Boîte de
dialogue" ne sait aujourd'hui afficher qu'une réplique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:48:14 +02:00
williamandClaude Sonnet 5 f2ed602459 Fix boutons Accepter/Refuser absents de l'écran d'offre de quête
Build and deploy / test-python (push) Successful in 6m47s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Le footer de la boîte de dialogue n'avait pas data-dialogue-role="footer"
(seul son bouton interne "Suivant" l'avait, sous "next-btn") — le JS
(forgeShowQuestOffer, dialogue-box-controller.js) le cherche par ce
sélecteur pour y injecter les boutons Accepter/Refuser en fin de
dialogue d'une quête "nouvelle". querySelector ne trouvait donc jamais
le footer, et l'écran d'offre gardait silencieusement le bouton
"Suivant" au lieu des deux nouveaux boutons — exactement le bug signalé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:04:26 +02:00
williamandClaude Sonnet 5 e90d941527 Quête "nouvelle" : écran Accepter/Refuser à la fin du dialogue
Build and deploy / test-python (push) Successful in 7m13s
Build and deploy / test-js (push) Successful in 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Une fois le dialogue épuisé, si la quête est encore "nouvelle", la
boîte de dialogue bascule sur un écran d'offre au lieu de se masquer :
header "Quête : <titre>", body l'objectif, pied deux boutons.

- Accepter -> la quête passe "en_cours" (en MÉMOIRE seulement,
  gameData.quests — jamais persisté en base : le statut en base est
  celui de DÉPART pour toute nouvelle partie, pas un état de partie en
  cours, voir PLAYER_SHARED/full_game_payload.py).
- Refuser -> la boîte se referme SANS toucher au statut, qui reste
  "nouvelle" : la prochaine interaction rejoue exactement le même
  dialogue depuis le début — impossible d'avancer la quête sans
  l'accepter un jour.

Pour tout autre statut (déjà "en_cours"/"terminee"), la boîte se
masque normalement à la fin du dialogue, comme avant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:00:02 +02:00
williamandClaude Sonnet 5 df34106752 Collision : une carte par objet occupe toute la largeur
Build and deploy / test-python (push) Successful in 6m24s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
L'ancienne grille (auto-fill, colonnes de 300px minimum) réservait des
colonnes vides même avec une seule carte, la laissant étroite avec un
grand vide à droite. Empilées en pleine largeur à la place — laisse
aussi la place à une longue chaîne de règles de s'étendre sans se
faire couper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:42:36 +02:00
williamandClaude Sonnet 5 aa17614c7b Fix bulle "Appuie sur X" trop haute au-dessus du personnage
Build and deploy / test-python (push) Successful in 6m7s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
La bulle s'ancrait sur le cadre BRUT du sprite (<img> style.left/top/
width/height) — beaucoup de sprites (animaux CraftPix notamment, voir
screens/labels/animal_sprite_library.py) ont un très grand canevas
padé autour d'une silhouette bien plus petite, faisant flotter la
bulle loin au-dessus du personnage visible.

Elle s'ancre maintenant sur la BOÎTE DE COLLISION (objRect, déjà
résolue via forgeElementBoxRect — largeur/hauteur/décalage réglables
dans "🧱 Collision"), que l'auteur ajuste déjà pour épouser la
silhouette réelle : ancre bien plus fidèle, sans configuration
supplémentaire à faire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:16:24 +02:00
williamandClaude Sonnet 5 00f9cf33e9 Fix bulle "Appuie sur X" invisible + retire Attaque/Événement du picker
Build and deploy / test-python (push) Successful in 6m12s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Bug corrigé : la bulle "Appuie sur X" était ajoutée comme ENFANT de
l'objet de scène (un <img> pour "personnage"/"decor"/"fond") — un
élément REMPLACÉ dont les enfants DOM ajoutés en JS ne sont JAMAIS
affichés par un navigateur, quel que soit son CSS. La bulle existait
donc bien dans le DOM (aucune erreur) mais restait invisible à l'écran.
Elle est maintenant ajoutée comme SŒUR de l'objet dans son parent
(.sceneWorld/.sceneUI), positionnée en JS aux mêmes coordonnées.

Assistant "+ Action" de l'éditeur de collision : "⚔️ Attaquer la cible"
et "📣 Déclencher un événement" retirés des choix proposés (pas besoin
pour le moment) — le serveur continue d'accepter/d'exécuter une règle
déjà enregistrée avec l'un des deux, rien ne casse pour l'existant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:58:00 +02:00
williamandClaude Sonnet 5 726775fe4e Fix "à la collision" — la marge de 3px restait insuffisante
Build and deploy / test-python (push) Successful in 5m56s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
forgeWouldCollide (personnage-controller.js) bloque le déplacement par
PAS ENTIER (cmd.vitesse px/tick, 4 par défaut mais réglable) : le
joueur peut donc rester bloqué jusqu'à PRESQUE un pas entier de
distance de l'obstacle, pas seulement 1-3px — la marge de tolérance du
correctif précédent (3px) restait insuffisante même pour la vitesse par
défaut dans les cas les moins favorables. Portée à 24px, confortable
pour toute vitesse raisonnablement configurée sans jamais déclencher
"à la collision" alors que les objets sont encore visiblement séparés.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:42:17 +02:00
williamandClaude Sonnet 5 3a67ba622b Fix "à la collision" qui ne se déclenchait jamais
Build and deploy / test-python (push) Successful in 6m27s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Cause réelle : un objet portant une règle de collision est presque
toujours AUSSI un obstacle solide (collision activée par défaut, voir
forgeWouldCollide, personnage-controller.js) — le déplacement du joueur
est donc bloqué PILE au contact, sans jamais laisser les deux boîtes se
chevaucher réellement. Le déclencheur "collision" testait un
chevauchement STRICT (forgeShapesOverlap nu), qui n'était donc jamais
atteint : la règle ne se déclenchait jamais en jeu réel, malgré un
contact visible à l'écran.

Le déclencheur teste maintenant la boîte de l'objet élargie d'une
petite marge (3px) — assez pour détecter un simple contact — SANS
toucher au blocage physique du déplacement (resté strict, inchangé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:33:59 +02:00
williamandClaude Sonnet 5 5e17e42dfa Test bout en bout : collision -> quête -> boîte de dialogue
Build and deploy / test-python (push) Successful in 6m11s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Vérifie explicitement le lien moteur demandé : une règle de collision
"quete" retrouve la quête, récupère les répliques de son statut actuel,
les affiche dans l'ordre dans le widget "💬 Boîte de dialogue" posé sur
la scène, avance au clic sur "Suivant", disparaît à la dernière
réplique — déjà implémenté (collision-rules-controller.js +
dialogue-box-controller.js), ce test couvre la CHAÎNE COMPLÈTE plutôt
que chaque brique isolément.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:27:50 +02:00
williamandClaude Sonnet 5 077af2234d Fix "+ Réplique" qui remplaçait la bulle au lieu d'en ajouter une
Build and deploy / test-python (push) Successful in 7m9s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Bug : questSaveDialogues() adoptait la réponse du serveur comme nouvel
état local après chaque enregistrement. Une bulle fraîchement ajoutée a
un texte encore vide (rien tapé) — sanitize_quest_dialogues.py la
rejette légitimement (jamais de réplique sans texte persistée) — donc
la réponse renvoyait un tableau amputé de cette bulle, que le client
adoptait aveuglément, effaçant la bulle en cours d'écriture. Le clic
suivant sur "+ Réplique" repartait donc du même état qu'avant, semblant
"remplacer" la bulle plutôt que d'en ajouter une seconde. Le client ne
resynchronise plus jamais son état depuis la réponse d'enregistrement —
il est déjà la seule source de vérité pendant l'édition.

Colonnes de dialogue : occupent maintenant toute la hauteur disponible
de la modale plein écran et défilent chacune indépendamment (titre et
bouton "+ Réplique" restent fixes), au lieu d'une hauteur minimale fixe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:08:41 +02:00
williamandClaude Sonnet 5 521792fe00 Dialogues de quête : bulles illimitées, n'importe quel objet, timeline reliée
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
- "qui parle" n'est plus réservé au personnage : N'IMPORTE QUEL objet de
  scène nommé (personnage, décor, fond — voir "ℹ️ Informations",
  screens/rendering/scene_object_names.py, ex-personnage_names.py) peut
  parler dans un dialogue.
- Confirmé/documenté : aucune limite au nombre de répliques par colonne
  (bouton "+ Réplique" reste toujours disponible).
- Bulle = header (menu déroulant du "qui parle") + body (le texte),
  centrée dans sa colonne à 70% de largeur, alignée verticalement et
  reliée à la suivante par un trait — remplace l'ancien alignement
  gauche/droite façon chat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:54:06 +02:00
williamandClaude Sonnet 5 2347a70c45 Quêtes/collision : personnages nommés, dialogues qui parlent, boîte de dialogue en jeu
Build and deploy / test-python (push) Successful in 11m32s
Build and deploy / test-js (push) Successful in 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
- "ℹ️ Informations" (propriétés d'un personnage) : nom éditable, réutilisé
  comme "qui parle" dans l'éditeur de dialogue de quête (menu déroulant,
  plus "Joueur" toujours disponible) — remplace l'ancien choix binaire
  joueur/pnj. db.sanitize_quest_dialogues accepte maintenant un nom libre.

- Nouveau menu "🖥️ Interface" (palette d'objets) avec le premier widget :
  "💬 Boîte de dialogue" (kind="dialogue_box", screens/rendering/
  dialogue_box_style.py) — position/taille comme tout objet de scène,
  panneau "🎨 Style" dédié (police, taille, épaisseur, couleur du texte,
  couleur header/body/footer). Rendu en <div> à 3 zones, jamais soumis à
  la collision ni à l'éditeur de collision (exclu partout : obstacles,
  liste des règles, payload). Rendu côté jeu dans .sceneUI, une couche
  FIXE au viewport (jamais .sceneWorld, qui défile avec la caméra).

- static/js/play/dialogue-box-controller.js : fait le lien moteur entre
  l'action "quete" de l'éditeur de collision et ce widget — affiche la
  réplique en cours du dialogue correspondant au STATUT ACTUEL de la
  quête (gameData.quests, désormais exposé game-wide par
  full_game_payload.py), avance au clic sur "Suivant" (header = qui
  parle, body = texte, footer = bouton), se masque à la dernière réplique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:20:58 +02:00
246 changed files with 3805 additions and 12349 deletions
+4
View File
@@ -37,3 +37,7 @@ assets/background/
Thumbs.db
.vscode/
.idea/
# Documents internes/business (cadrage produit...) et état local de
# session Claude Code — jamais du code, pas destiné à l'historique partagé.
.claude/
+9 -1
View File
@@ -33,4 +33,12 @@ def _open_browser():
if __name__ == "__main__":
threading.Timer(1.0, _open_browser).start()
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False)
# threaded=True : le serveur de dev Werkzeug traite UNE requête à la
# fois par défaut — un jeu 2D charge des dizaines d'images (sprites de
# personnages, fonds...) en parallèle à chaque chargement de page, et
# sans ce réglage elles se mettaient en file d'attente une par une
# (bug remonté : un Ctrl+F5, qui recharge TOUT sans cache, laissait
# l'éditeur de scène "vide" le temps que les images finissent par
# arriver l'une après l'autre — un rechargement normal, servi surtout
# depuis le cache navigateur, le cachait).
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True)
-6
View File
@@ -1,12 +1,6 @@
import screens
from core.flask_app import app
from filters.colname_filter import _colname
from filters.element_style_filter import _element_style, _element_transform_style
from filters.truncate_sentences_filter import _truncate_sentences
app.jinja_env.filters["colname"] = _colname
app.jinja_env.filters["elstyle"] = _element_style
app.jinja_env.filters["eltransform"] = _element_transform_style
app.jinja_env.filters["elabel"] = screens.element_label
app.jinja_env.filters["truncatesentences"] = _truncate_sentences
+5 -4
View File
@@ -79,7 +79,7 @@ from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
from .quests.constants import (
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES,
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS,
)
from .quests.list_quests import list_quests
from .quests.get_quest import get_quest
@@ -87,7 +87,8 @@ from .quests.create_quest import create_quest
from .quests.update_quest import update_quest
from .quests.delete_quest import delete_quest
from .quests.set_quest_dialogues import set_quest_dialogues
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues
from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues, QUESTION_REWARD_TYPES
from .quests.reward_budget import quest_dialogues_total_reward
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
@@ -106,7 +107,7 @@ __all__ = [
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES",
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
"set_quest_dialogues", "sanitize_quest_dialogues",
"set_quest_dialogues", "sanitize_quest_dialogues", "QUESTION_REWARD_TYPES", "quest_dialogues_total_reward",
]
+31 -34
View File
@@ -15,48 +15,45 @@ rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py)."""
import db
DEFAULT_ONBOARDING_TYPE = "custom"
DEFAULT_ONBOARDING_TYPE = "rpg"
ONBOARDING_TYPES = {
"quiz": {
"label": "Quiz",
"tagline": "Questions, réponses, score.",
"icon": "🎯",
"description": "Gérer des questions/réponses, avec score.",
"screen_kind": "document",
"restricted": True,
},
"embranchement": {
"label": "Jeu à embranchement / escape game",
"tagline": "Chaque choix a une conséquence.",
"icon": "🗝️",
"description": (
"Former à travers le jeu sur des concepts métier avec des "
"conséquences observables ; réaliser des escape games sous "
"forme d'énigmes."
),
"screen_kind": "document",
"restricted": True,
},
"rpg": {
"label": "Jeu RPG",
"label": "Créer un jeu 2D ludique",
"tagline": "Ton monde, ton héros, ton scénario.",
"icon": "🗺️",
"description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.",
# Argumentaire pour un public RH non technique (voir .claude/
# Forge_Engine_Cadrage.pdf) : chaque point correspond à une
# fonctionnalité RÉELLEMENT construite (assistant "+ Action" de
# l'éditeur de collision pour les interactions, "❓ Boîte à quiz"/
# "🏆 Score" posables comme n'importe quel objet de scène, dialogue
# de quête par état "Nouvelle/En cours/Terminée", export SCORM 1.2
# natif), jamais une promesse. Du HTML de confiance (jamais de
# saisie utilisateur ici) — rendu via `|safe` dans
# templates/onboarding/onboarding_new.html, stylé par
# .onboardingPitch* dans static/style.css : liste à coche + badge
# LMS, pensé pour convaincre en un coup d'œil plutôt qu'un pavé de
# texte brut.
"description": (
'<span class="onboardingPitchTitle">Un jeu 2D ludique,'
" sans une ligne de code</span>"
'<ul class="onboardingPitchList">'
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Personnages vivants</strong> — posez-les, donnez-leur"
" un rôle, ils bougent et réagissent seuls</span></li>"
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Quiz animés</strong> — intégrés à la scène,"
" correction immédiate</span></li>"
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Score &amp; dialogues</strong> — évoluent avec"
" chaque quête</span></li>"
"</ul>"
'<span class="onboardingPitchBadge">🎓 Compatible LMS —'
" export SCORM en 1 clic</span>"
),
"screen_kind": "jeu_2d",
"restricted": True,
},
"custom": {
"label": "Créer mon jeu de A à Z",
"tagline": "Toute la puissance du moteur, sans limite.",
"icon": "⚡",
"description": (
"Pour les utilisateurs avec des notions de dev/jeu vidéo : "
"accès à toute la puissance du moteur."
),
"screen_kind": "document",
"restricted": False,
},
}
+1 -1
View File
@@ -1,6 +1,6 @@
from ..connection import connect
DEFAULT_GAME_TYPE = "document"
DEFAULT_GAME_TYPE = "jeu_2d"
def get_game_type(slug):
-1
View File
@@ -2,4 +2,3 @@ QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
QUEST_SPEAKER_CHOICES = ["joueur", "pnj"]
+15
View File
@@ -0,0 +1,15 @@
def quest_dialogues_total_reward(dialogues):
"""Somme des récompenses de toutes les questions ("❓ Question", voir
sanitize_quest_dialogues.py), TOUTES colonnes/statuts confondus — une
quête n'a qu'UNE SEULE récompense totale (quest["recompense_score"]),
jamais une par statut. Utilisée pour empêcher d'enregistrer plus de
points répartis entre les questions que la quête n'en offre (voir
routes/quests/quest_dialogues.py et quest_update.py)."""
total = 0
for lines in (dialogues or {}).values():
if not isinstance(lines, list):
continue
for line in lines:
if isinstance(line, dict) and line.get("type") == "question":
total += line.get("reward_amount", 0) or 0
return total
+69 -17
View File
@@ -1,13 +1,75 @@
from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES
from .constants import QUEST_STATUS_CHOICES
_MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60
# Question à choix (voir "❓ Question", static/js/quests/quest-editor.js) :
# une bulle jaune posée dans le même enchaînement qu'une réplique de
# dialogue — plusieurs choix, une seule bonne réponse, une récompense.
# Un seul type de récompense pour l'instant (le score, voir db.SCORE_STATUS_*/
# db.set_score_value) — liste plutôt qu'un booléen pour pouvoir en ajouter
# d'autres plus tard sans changer la forme des données.
QUESTION_REWARD_TYPES = ["score"]
_MIN_CHOICES = 2
_MAX_CHOICES = 4
def _sanitize_dialogue_line(raw_line):
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if not isinstance(speaker, str) or not speaker.strip():
return None
if not isinstance(text, str) or not text.strip():
return None
return {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()}
def _sanitize_question_line(raw_line):
text = raw_line.get("text")
if not isinstance(text, str) or not text.strip():
return None
raw_choices = raw_line.get("choices")
if not isinstance(raw_choices, list):
return None
choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
if len(choices) < _MIN_CHOICES:
return None
try:
correct_index = int(raw_line.get("correct_index"))
except (TypeError, ValueError):
return None
if not (0 <= correct_index < len(choices)):
return None
reward_type = raw_line.get("reward_type")
if reward_type not in QUESTION_REWARD_TYPES:
reward_type = QUESTION_REWARD_TYPES[0]
try:
reward_amount = max(0, int(raw_line.get("reward_amount", 0)))
except (TypeError, ValueError):
reward_amount = 0
return {
"type": "question", "text": text.strip(), "choices": choices,
"correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount,
}
def _sanitize_line(raw_line):
if not isinstance(raw_line, dict):
return None
if raw_line.get("type") == "question":
return _sanitize_question_line(raw_line)
return _sanitize_dialogue_line(raw_line)
def sanitize_quest_dialogues(raw_dialogues):
"""Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne
par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que
resolve_collision_rules.sanitize_collision_rules : rejette tout
élément invalide plutôt que de lever, ne garde jamais un texte vide."""
"""Une colonne (voir QUEST_STATUS_CHOICES) contient une suite de
répliques de dialogue ({type: "dialogue", speaker, text} — le nom
vient du champ "ℹ️ Informations" d'un objet de scène, ou "Joueur",
voir screens/rendering/scene_object_names.py) ET/OU de questions à
choix ({type: "question", text, choices, correct_index, reward_type,
reward_amount}, voir QUESTION_REWARD_TYPES ci-dessus) — même esprit
que resolve_collision_rules.sanitize_collision_rules : rejette tout
élément invalide plutôt que de lever."""
result = {status: [] for status in QUEST_STATUS_CHOICES}
if not isinstance(raw_dialogues, dict):
return result
@@ -15,16 +77,6 @@ def sanitize_quest_dialogues(raw_dialogues):
raw_lines = raw_dialogues.get(status)
if not isinstance(raw_lines, list):
continue
cleaned = []
for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]:
if not isinstance(raw_line, dict):
continue
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if speaker not in QUEST_SPEAKER_CHOICES:
continue
if not isinstance(text, str) or not text.strip():
continue
cleaned.append({"speaker": speaker, "text": text.strip()})
result[status] = cleaned
cleaned = [_sanitize_line(raw_line) for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]]
result[status] = [line for line in cleaned if line]
return result
-76
View File
@@ -1,76 +0,0 @@
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).
Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà
x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999
sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même
"z-index:{z_index}" (le z-index de sa place dans le canevas, souvent
petit), un élément positionné avec un z-index EXPLICITE (même sur ce
cadre, pas sur son contenu) devient un NOUVEAU contexte d'empilement
CSS — le z-index:9999 posé plus profond ne se comparait alors plus
qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un
frère ajouté après lui sur le canevas, qui se retrouvait affiché
PAR-DESSUS le dialogue censé tout couvrir.
Première correction tentée (retirer aussi position/left/top/width/
height ici) : régression dans l'ÉDITEUR — ce même cadre (.canvasElement)
sert aussi de prise pour glisser-déposer/redimensionner CE widget dans
le canevas (voir screen_edit.html), dont le calcul se base sur ses
dimensions réelles ; sans position:absolute + left/top/width/height, ce
cadre s'effondre à 0×0 (son contenu réel est en position:fixed, donc
hors flux), le calcul division par zéro produit NaN, et
JSON.stringify(NaN) donne "null" — element_geometry.py plantait alors
sur float(None). left/top/width/height restent donc posés comme pour
n'importe quel widget (l'éditeur continue de fonctionner normalement) :
SEUL le z-index est omis pour ce widget précis. position:absolute SANS
z-index explicite (donc z-index:auto) ne crée PAS de nouveau contexte
d'empilement — le z-index:9999 posé plus profond se compare alors
directement aux autres éléments de l'écran, et gagne toujours.
Même omission pour un exemplaire (element_type_id) d'un élément de jeu
dont le modèle N'EST QU'une superposition (marqué "_overlay_only_instance"
par list_elements.py) : son propre widget est "conteneur" par défaut
(add_element.py), pas "superposition", mais son cadre entoure quand
même une superposition rendue à l'intérieur (voir
render_element_html.py/_render_element_type_children) — exactement le
même risque de contexte d'empilement piégeant."""
exempt_from_z_index = (
el.get("widget") == "superposition" or el.get("_overlay_only_instance")
)
parts = [
"position:absolute",
f"left:{el['x']}%", f"top:{el['y']}%",
f"width:{el['width']}%", f"height:{el['height']}%",
]
if not exempt_from_z_index:
parts.append(f"z-index:{el['z_index']}")
return "; ".join(parts) + ";"
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 ""
+1
View File
@@ -252,6 +252,7 @@ def build_scorm_zip(slug):
html = render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename,
scorm_api_wrapper_url="static/js/play/offline/scorm-api.js",
)
html = _relativize_absolute_urls(html, slug)
html = _inline_icon_urls(html, payload["icon_data_uris"])
+1 -1
View File
@@ -1 +1 @@
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests
-1
View File
@@ -1 +0,0 @@
from . import element_types, element_type_edit, element_type_delete
@@ -1,12 +0,0 @@
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("game_dashboard", slug=slug, tab="element_types"))
-16
View File
@@ -1,16 +0,0 @@
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("game_dashboard", slug=slug, tab="element_types"))
-22
View File
@@ -1,22 +0,0 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
@app.route("/game/<slug>/element-types", methods=["GET", "POST"])
def element_types(slug):
"""La création/gestion d'un élément de jeu se fait désormais depuis
l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html)
plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la
création (barre de création repliable de cet onglet). Un GET direct
redirige vers le tableau de bord."""
if request.method == "POST":
name = request.form.get("name", "").strip()
icon = request.form.get("icon", "").strip() or "🧩"
bound_definition_id = request.form.get("bound_definition_id") or None
if name:
screens.create_element_type(slug, name, icon, bound_definition_id)
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
return redirect(url_for("game_dashboard", slug=slug, tab="element_types"))
-14
View File
@@ -1,14 +0,0 @@
from . import (
element_add,
element_add_child,
element_geometry,
element_reorder,
element_move_to_container,
element_save,
element_delete,
element_delete_impact,
element_duplicate,
element_rename,
element_tab_add,
element_set_personnage_data,
)
-37
View File
@@ -1,37 +0,0 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@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)
icon_slug = request.form.get("icon_class")
if widget == "icone" and icon_slug in screens.FONTAWESOME_ICONS:
# Posé depuis la galerie d'icônes (voir le panneau de propriétés,
# section "Icônes") : l'icône choisie remplace celle posée par
# défaut à la création plutôt que de repartir d'une icône neutre que
# l'utilisateur devrait ensuite changer manuellement.
screens.save_element_controls(slug, new_id, {"ctrl_icon_class": icon_slug})
if widget == "personnage":
# Posé depuis la galerie "🎭 Personnages" (voir addPersonnageElement()
# dans tree-panels.js) : remplace le personnage Forge par défaut
# (male-adventurer, voir default_style_for_widget.py) par celui
# réellement cliqué.
forge_character = request.form.get("forge_character")
if forge_character and forge_character in screens.SPRITE_LIBRARY:
forbid_admin_only_character(forge_character)
screens.set_personnage_data(slug, new_id, {"source": "forge", "forge_character": forge_character})
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
-25
View File
@@ -1,25 +0,0 @@
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
@@ -1,22 +0,0 @@
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))
-16
View File
@@ -1,16 +0,0 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/delete-impact")
def element_delete_impact(slug, element_id):
"""Appelé par la modale de confirmation de suppression (screen_edit.html)
AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la
Logique de la scène (déclencheur/action) seraient aussi supprimés en
même temps que l'élément — voir delete_element.py, qui les supprime
silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY
constraint failed", voir son commentaire)."""
return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)})
-14
View File
@@ -1,14 +0,0 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
def element_duplicate(slug, element_id):
element = screens.get_element(slug, element_id)
if not element:
abort(404)
new_id = screens.duplicate_element(slug, element_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
-20
View File
@@ -1,20 +0,0 @@
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})
@@ -1,21 +0,0 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
def element_move_to_container(slug, element_id):
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
dessous — le fait passer enfant de ce conteneur, en dernière position
(voir move_element_to_container, qui refuse aussi silencieusement les
cas invalides : cible non-conteneur, ou cycle)."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
new_parent_id = int(data["parent_id"])
screens.move_element_to_container(slug, element_id, new_parent_id)
return jsonify({"ok": True})
-14
View File
@@ -1,14 +0,0 @@
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))
-22
View File
@@ -1,22 +0,0 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/reorder", methods=["POST"])
def element_reorder(slug, element_id):
"""Appelé en AJAX quand on glisse un élément au-dessus/en dessous d'un
autre DANS LA MÊME ARBORESCENCE (panneau de gauche) pour changer l'ordre
des éléments d'un même conteneur — voir reorder_element."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
target_id = int(data["target_id"])
position = data.get("position")
if position not in ("before", "after"):
abort(400)
screens.reorder_element(slug, element["screen_id"], element_id, target_id, position)
return jsonify({"ok": True})
-18
View File
@@ -1,18 +0,0 @@
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))
@@ -1,24 +0,0 @@
from flask import request, jsonify
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_character
@app.route("/game/<slug>/elements/<int:element_id>/personnage", methods=["POST"])
def element_set_personnage_data(slug, element_id):
"""Change le personnage Forge d'un élément déjà posé (galerie du
panneau de propriétés), ou met à jour ses animations personnalisées
(import de frames) — voir personnage-panel.js. Renvoie les animations
résolues pour que le client relance immédiatement l'aperçu animé sans
recharger la page (voir personnage-preview.js)."""
body = request.get_json(silent=True) or {}
el = screens.get_element(slug, element_id)
if not el:
return jsonify({"error": "élément introuvable"}), 404
if body.get("source") == "forge" and body.get("forge_character"):
forbid_admin_only_character(body["forge_character"])
screens.set_personnage_data(slug, element_id, body)
el = screens.get_element(slug, element_id)
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(el)})
-14
View File
@@ -1,14 +0,0 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
def element_tab_add(slug, element_id):
onglets = screens.get_element(slug, element_id)
if not onglets:
abort(404)
screens.add_tab(slug, element_id, onglets["screen_id"])
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
+14 -71
View File
@@ -8,25 +8,12 @@ from core.flask_app import app
@app.route("/game/<slug>")
def game_dashboard(slug):
"""Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets,
éléments de jeu réutilisables, variables), en un coup d'œil, avec un
accès direct à chaque élément — la barre de navigation du jeu (voir
base.html) permet déjà de sauter directement vers chaque éditeur, cette
page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes
liens.
Onboarding guidé (voir db/games/game_type_catalog.py) : un compte
"restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE —
juste ses écrans, en cartes avec un aperçu de leur contenu, plus un
bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le
projet : "jeu_2d" pour un RPG, "document" pour les autres — voir
routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/
variables restent réservés à "Créer mon jeu de A à Z", la seule
formule pensée pour la puissance complète du moteur."""
"""Vue d'ensemble du jeu : juste ses écrans, en cartes avec un aperçu
de leur contenu, plus un bouton "+ Ajouter un écran" qui ne demande pas
de type (un seul moteur d'écran : jeu_2d — voir
routes/screens/screens_new.py)."""
game = db.game_meta(slug)
if db.is_restricted(slug):
return _render_simple_dashboard(slug, game)
return _render_full_dashboard(slug, game)
return _render_simple_dashboard(slug, game)
def _render_simple_dashboard(slug, game):
@@ -37,61 +24,17 @@ def _render_simple_dashboard(slug, game):
objects = screens.list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
previews[s["id"]] = {"kind": "jeu_2d", "objects": objects}
else:
# Taille du MONDE (voir screens.resolve_scene_world_size,
# même calcul que l'éditeur, routes/scenes/scene_edit_view.py)
# plutôt que scene_width/scene_height (la caméra nominale) :
# un "fond" plus grand que la caméra dépassait alors le cadre
# de la miniature (game_dashboard_simple.html), qui n'en
# montrait que le coin haut-gauche au lieu de la scène entière.
world_width, world_height = screens.resolve_scene_world_size(s, objects)
previews[s["id"]] = {
"kind": "document",
"elements": screens.list_elements(slug, s["id"]),
"kind": "jeu_2d", "objects": objects,
"world_width": world_width, "world_height": world_height,
}
return render_template(
"game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews,
)
def _render_full_dashboard(slug, game):
definitions = db.list_definitions(slug)
# Détail complet (avec ses champs) de chaque objet, pour le panneau
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
# ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
# l'ouverture (voir openObjectEditPanel()).
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
# objet, les libellés lisibles de ses champs relation (pour l'affichage
# du tableau) et les options possibles de ces mêmes champs relation
# (pour le formulaire "Ajouter une entrée").
rows_by_definition = {}
relation_labels_by_definition = {}
relation_options_by_definition = {}
referenced_by_definition = {}
for d in definitions:
full = definitions_full[d["id"]]
rows_by_definition[d["id"]] = db.list_rows(slug, full)
relation_labels = {}
relation_options = {}
for f in full["fields"]:
if f["type"] == "relation":
options = db.relation_options(slug, f["relation_definition_id"])
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
relation_options[f["id"]] = options
relation_labels_by_definition[d["id"]] = relation_labels
relation_options_by_definition[d["id"]] = relation_options
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
screen_list = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
variables = db.list_global_variables(slug)
return render_template(
"game_dashboard.html", game=game, definitions=definitions,
definitions_full=definitions_full,
rows_by_definition=rows_by_definition,
relation_labels_by_definition=relation_labels_by_definition,
relation_options_by_definition=relation_options_by_definition,
referenced_by_definition=referenced_by_definition,
screen_list=screen_list, element_types=element_types,
element_type_usage=element_type_usage, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
)
-1
View File
@@ -1 +0,0 @@
from . import action_add, action_delete, action_run_data
-35
View File
@@ -1,35 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,17 +0,0 @@
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
@@ -1,14 +0,0 @@
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,
)
-21
View File
@@ -1,21 +0,0 @@
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 au panneau de cet objet (onglet
# Données), qui explique pourquoi via un paramètre de requête
# simple plutôt qu'une session/flash message.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
db.delete_row(slug, definition, row_id)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
-17
View File
@@ -1,17 +0,0 @@
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)
-24
View File
@@ -1,24 +0,0 @@
from flask import request, redirect, url_for, abort
import db
def _data_form(slug, definition_id, row):
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
(data_form.html, retirée) — cette fonction ne fait donc plus que
traiter la soumission de ce formulaire. Un GET direct redirige vers le
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
Données."""
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
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("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
-7
View File
@@ -1,7 +0,0 @@
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)
-19
View File
@@ -1,19 +0,0 @@
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 à son panneau, qui affiche déjà cet
# avertissement (referenced_by_definition, voir game_dashboard.py)
# en permanence, pas seulement après une tentative — plutôt que de
# casser ces relations silencieusement.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
db.delete_definition(slug, definition_id)
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
-24
View File
@@ -1,24 +0,0 @@
from flask import 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):
"""Renommer un objet + gérer ses champs se fait désormais depuis un
panneau déplaçable/redimensionnable sur le tableau de bord (voir
game_dashboard.html, ouvert par le crayon ✏️ de l'onglet Objets) plutôt
qu'une page dédiée — cette route ne fait donc plus que traiter le
renommage. Un GET direct redirige vers le tableau de bord, en rouvrant
le panneau de cet objet (?edit=<id>)."""
if request.method == "POST":
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
new_name = request.form.get("object_name", "").strip()
if new_name and new_name != definition["name"]:
db.rename_definition(slug, definition_id, new_name)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-26
View File
@@ -1,26 +0,0 @@
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()
field_type = request.form.get("field_type", "texte")
relation_definition_id = request.form.get("field_relation")
# Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet
# cible valide ferait planter add_field_to_definition (int("") lève),
# ce qui annulait l'ALTER TABLE avant son commit() -- le champ
# n'était alors ajouté du tout, sans le moindre message d'erreur.
if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()):
db.add_field_to_definition(slug, definition_id, {
"name": name,
"type": field_type,
"relation_definition_id": relation_definition_id,
"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("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-11
View File
@@ -1,11 +0,0 @@
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("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-20
View File
@@ -1,20 +0,0 @@
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("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-35
View File
@@ -1,35 +0,0 @@
from flask import 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):
"""La création d'un objet se fait désormais depuis un panneau déplaçable
sur le tableau de bord du jeu (voir game_dashboard.html, formulaire
#objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus
que traiter la soumission de CE formulaire. Un GET direct (ex. lien
externe obsolète) redirige simplement vers le tableau de bord."""
if request.method == "POST":
name = request.form.get("object_name", "").strip()
# Aucun champ requis à la création : create_definition(fields=[])
# crée simplement une table sans colonne "métier" (juste id/
# created_at), et le panneau "Modifier un objet" (onglet Champs,
# bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins
# un champ ici faisait échouer silencieusement la création (aucun
# message d'erreur, juste un retour au tableau de bord) dès qu'on
# tapait le nom sans avoir encore cliqué "+ Ajouter un champ".
fields = _parse_field_rows(request.form)
# État par joueur (Phase 1) : coché par défaut (une case DÉCOCHÉE
# explicitement rend l'objet partagé par tous les joueurs — ex. un
# catalogue/une liste de questions — voir db/definitions/
# create_definition.py).
per_player = request.form.get("per_player", "1") == "1"
if name:
definition_id = db.create_definition(slug, name, fields, per_player=per_player)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug))
-18
View File
@@ -1,18 +0,0 @@
from flask import redirect, url_for
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>")
def object_view(slug, definition_id):
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
retirée : les données vivent désormais dans l'onglet "Données" du
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
route reste néanmoins en place, comme pur redirecteur, pour deux
raisons : un lien externe/ancien vers cette URL continue de mener
quelque part de sensé, et surtout beaucoup de tests (et certains
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
segment du chemin renvoyé par le header Location d'une redirection
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
termine par l'id), qui redirige ensuite vers le tableau de bord."""
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
-36
View File
@@ -1,36 +0,0 @@
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
ftype = field_types[i]
relation_definition_id = field_relations[i] if i < len(field_relations) else None
if ftype == "relation" and not (relation_definition_id or "").strip().isdigit():
# Type "Relation vers un autre objet" choisi sans (ou avant
# d'avoir pu) sélectionner d'objet cible réel — ex. le tout
# premier objet créé dans un jeu, quand ce sélecteur est encore
# vide faute d'un autre objet à pointer. create_definition()
# fait un int(relation_definition_id) sans filet : le laisser
# passer plantait TOUTE la requête (500), et comme la ligne
# _definitions n'était alors jamais validée (pas de commit()
# atteint), l'objet n'était pas créé du tout — pas de champ
# "Relation" non plus, mais un panneau qui semble "ne rien
# faire" plutôt qu'un message d'erreur clair. On ignore donc ce
# champ précis (comme une ligne sans nom, ci-dessus) plutôt que
# de faire échouer la création de TOUT l'objet à cause de lui.
continue
fields.append({
"name": fname.strip(),
"type": ftype,
"relation_definition_id": relation_definition_id,
"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
+21 -4
View File
@@ -9,9 +9,26 @@ from core.flask_app import app
def quest_dialogues(slug, quest_id):
"""Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) —
le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet
complet à chaque modification plutôt qu'une réplique isolée."""
body = request.get_json(silent=True) or {}
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
if dialogues is None:
complet à chaque modification plutôt qu'une réplique isolée.
Refusé (400) si la somme des récompenses des questions ("❓ Question")
dépasse la récompense totale de la quête (recompense_score) — voir
db.quest_dialogues_total_reward. Validé ICI (avant persistance)
plutôt que dans db.set_quest_dialogues, pour renvoyer un message
d'erreur explicite au lieu d'enregistrer silencieusement un état
incohérent."""
quest = db.get_quest(slug, quest_id)
if quest is None:
return jsonify({"error": "quête introuvable"}), 404
body = request.get_json(silent=True) or {}
sanitized = db.sanitize_quest_dialogues(body.get("dialogues"))
allocated = db.quest_dialogues_total_reward(sanitized)
if allocated > quest["recompense_score"]:
return jsonify({
"error": (
f"La somme des récompenses des questions ({allocated} pts) dépasse "
f"la récompense totale de la quête ({quest['recompense_score']} pts)."
),
}), 400
dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues"))
return jsonify({"dialogues": dialogues})
+22
View File
@@ -7,7 +7,29 @@ from core.flask_app import app
@app.route("/game/<slug>/quests/<int:quest_id>", methods=["POST"])
def quest_update(slug, quest_id):
"""Refusé (400) si "recompense_score" est abaissé SOUS le total déjà
réparti entre les questions ("❓ Question") du dialogue de cette
quête — voir db.quest_dialogues_total_reward, même contrainte que
routes/quests/quest_dialogues.py mais dans l'autre sens (ici c'est le
budget total qui bouge, pas la répartition)."""
body = request.get_json(silent=True) or {}
if "recompense_score" in body:
quest = db.get_quest(slug, quest_id)
if quest is None:
return jsonify({"error": "quête introuvable"}), 404
try:
new_score = int(body["recompense_score"])
except (TypeError, ValueError):
new_score = None
if new_score is not None:
allocated = db.quest_dialogues_total_reward(quest["dialogues"])
if new_score < allocated:
return jsonify({
"error": (
f"La récompense totale ({new_score} pts) est inférieure aux "
f"{allocated} pts déjà répartis entre les questions du dialogue."
),
}), 400
quest = db.update_quest(slug, quest_id, body)
if quest is None:
return jsonify({"error": "quête introuvable"}), 404
+4
View File
@@ -7,4 +7,8 @@ from . import (
scene_object_role,
scene_object_collision,
scene_object_collision_rules,
scene_object_name,
scene_object_dialogue_box_style,
scene_collision_rule_objects,
scene_object_names_list,
)
+61
View File
@@ -0,0 +1,61 @@
import screens
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
_COLLISION_EXCLUDED_KINDS = ("fond", "dialogue_box", "quiz_box", "score_widget")
def build_collision_rule_objects(objects):
"""Une carte "🧩 Collision" (voir templates/scene_edit.html) par objet
ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
screens/rendering/collision_rules.py) — factorisé ici plutôt que
dupliqué : utilisé au premier rendu de la page (routes/scenes/
scene_edit_view.py) ET par l'endpoint AJAX ci-dessous (appelé après
ajout/suppression/changement de rôle ou de nom, voir static/js/scenes/
scene-editor.js) pour reconstruire la liste SANS recharger la page.
`objects` doit déjà porter "rendered_html" (voir
screens.render_scene_object) sur chaque élément.
resolve_personnage_role retombe sur "joueur" pour TOUT personnage sans
rôle explicitement enregistré (voir screens/rendering/
personnage_role.py — comportement voulu pour une scène à un seul
personnage, posé avant l'ajout de ce réglage). Appliqué tel quel,
ÇA CASSE dès qu'une scène a plusieurs personnages sans rôle explicite
(le cas de toute scène créée avant ce réglage — un personnage ajouté
depuis l'éditeur reçoit maintenant TOUJOURS un rôle "pnj" explicite,
voir add_scene_object.py) : ils retomberaient TOUS sur "joueur" et
disparaîtraient donc TOUS de cette liste. On ne considère donc comme
"joueur" implicite QUE LE PREMIER personnage sans rôle explicite (le
plus ancien, z_index croissant comme `objects`) — les suivants sans
rôle explicite sont traités comme "pnj" ici, sans rien réécrire en
base."""
implicit_joueur_claimed = any(
(o.get("attributes") or {}).get("_personnage_role") == "joueur"
for o in objects if o["kind"] == "personnage"
)
result = []
for o in objects:
if o["kind"] in _COLLISION_EXCLUDED_KINDS:
continue
role = None
if o["kind"] == "personnage":
explicit_role = (o.get("attributes") or {}).get("_personnage_role")
if explicit_role in screens.PERSONNAGE_ROLES:
role = explicit_role
elif not implicit_joueur_claimed:
role = "joueur"
implicit_joueur_claimed = True
else:
role = "pnj"
if role == "joueur":
continue
result.append({
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
})
return result
@@ -0,0 +1,24 @@
from flask import jsonify
import screens
from core.flask_app import app
from .collision_rule_objects import build_collision_rule_objects
@app.route("/game/<slug>/screens/<int:screen_id>/collision-rule-objects", methods=["GET"])
def scene_collision_rule_objects(slug, screen_id):
"""Liste à jour des cartes "🧩 Collision" (voir COLLISION_RULE_OBJECTS,
templates/scene_edit.html) — appelée en AJAX après un ajout/suppression
d'objet, ou un changement de rôle/nom (voir static/js/scenes/
scene-editor.js) qui peut faire apparaître/disparaître un objet de
cette liste (rôle passé à/depuis "joueur", voir
build_collision_rule_objects) ou changer son libellé affiché (nom).
Recalculée entièrement plutôt que diffée : la liste reste petite (un
objet de scène par carte, jamais un "fond"/widget d'UI) et cette même
fonction est déjà appelée à chaque rendu complet de la page."""
objects = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
return jsonify(build_collision_rule_objects(objects))
+34 -23
View File
@@ -5,6 +5,8 @@ from flask import render_template, request, abort, g
import db
import screens
from .collision_rule_objects import build_collision_rule_objects
def render_scene_edit(slug, screen_id, game):
"""Vue de l'éditeur de scène 2D — appelée par routes/screens/
@@ -21,18 +23,36 @@ def render_scene_edit(slug, screen_id, game):
objects = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
# "Monde" (voir screens.resolve_scene_world_size, réutilisé aussi par
# la miniature de "Mes écrans", routes/games/game_dashboard.py) : un
# "fond" posé à sa taille RÉELLE (souvent plus grande que la scène,
# voir add_scene_object.py) rend visible/joignable, dans CET éditeur,
# tout ce qui dépasse la scène nominale plutôt que de le rogner (le
# glisser-déposer le permet déjà depuis le fix précédent — sans
# agrandir le canevas ici, cette partie restait invisible/injoignable
# à la souris). Le rectangle "🎥 Champ de la caméra" (voir
# templates/scene_edit.html) marque ensuite où s'arrête la scène
# nominale À L'INTÉRIEUR de ce monde plus grand.
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
selected_id = request.args.get("selected", type=int)
personnage_data = None
personnage_commands = None
personnage_role = None
collision_settings = None
dialogue_box_style = None
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
_UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget")
if selected_obj:
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] not in _UI_WIDGET_KINDS:
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(selected_obj)
personnage_role = screens.resolve_personnage_role(selected_obj)
elif selected_obj["kind"] in _UI_WIDGET_KINDS:
dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj)
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
object_labels = {
@@ -43,28 +63,11 @@ def render_scene_edit(slug, screen_id, game):
o["id"]: screens.resolve_personnage_animations(o) for o in objects if o["kind"] == "personnage"
}
# "🧩 Collision" (voir templates/scene_edit.html) : une carte par objet
# ÉLIGIBLE (jamais un "fond", jamais le personnage "joueur" — ce sont
# TOUJOURS les AUTRES objets qui réagissent à SA présence, voir
# screens/rendering/collision_rules.py) — construit ici plutôt que
# réutiliser `objects` tel quel, pour ne poser resolve_personnage_role
# qu'une fois par objet personnage (déjà lu ci-dessus pour l'objet
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
collision_rule_objects = []
for o in objects:
if o["kind"] == "fond":
continue
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
if role == "joueur":
continue
collision_rule_objects.append({
"id": o["id"],
"name": o.get("name") or _DEFAULT_OBJECT_LABELS.get(o["kind"], "Décor"),
"kind": o["kind"],
"role": role,
"thumbnail_html": o["rendered_html"],
"rules": screens.resolve_collision_rules(o),
})
# "🧩 Collision" — voir build_collision_rule_objects() (routes/scenes/
# collision_rule_objects.py, factorisé pour être réutilisé par
# l'endpoint AJAX scene_collision_rule_objects.py, appelé après ajout/
# suppression/changement de rôle ou de nom sans recharger la page).
collision_rule_objects = build_collision_rule_objects(objects)
flow_blocks = screens.list_flow_blocks(slug, screen_id)
flow_nodes = screens.list_flow_nodes(slug, screen_id)
@@ -90,11 +93,14 @@ def render_scene_edit(slug, screen_id, game):
return render_template(
"scene_edit.html",
game=game, screen=screen, all_screens=all_screens,
world_width=world_width, world_height=world_height,
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
dialogue_box_style=dialogue_box_style or screens.DEFAULT_DIALOGUE_BOX_STYLE,
dialogue_box_font_weight_choices=screens.FONT_WEIGHT_CHOICES,
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
background_library=background_library,
@@ -134,4 +140,9 @@ def render_scene_edit(slug, screen_id, game):
# existante, sans attendre l'ouverture de l'assistant "+ Action"
# (seul moment où questPickerHtml irait la chercher sinon).
quests_json=json.dumps(db.list_quests(slug)),
# "qui parle" dans le dialogue d'une quête (voir "ℹ️ Informations",
# static/js/quests/quest-editor.js) : les noms donnés à N'IMPORTE
# QUEL objet de scène (personnage, décor, fond), toutes scènes du
# jeu confondues.
scene_object_names_json=json.dumps(screens.list_named_scene_object_names(slug)),
)
+30 -2
View File
@@ -1,4 +1,4 @@
from flask import request, redirect, url_for
from flask import request, redirect, url_for, jsonify
import screens
@@ -10,7 +10,20 @@ from core.sprite_gate import forbid_admin_only_character, forbid_admin_only_back
def scene_object_add(slug, screen_id):
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
element_add.py, mais pour _scene_objects (voir screens/scenes/
add_scene_object.py)."""
add_scene_object.py).
Appelée en AJAX (voir addSceneObject(), static/js/scenes/scene-editor.js)
plutôt qu'en soumission de formulaire classique — sans ça, poser un
objet rechargeait TOUTE la page : perceptible comme un "vidage" de
l'éditeur de collision/quêtes le temps du rechargement, et perdait tout
état JS en cours (modale ouverte, etc.). Répond en JSON avec de quoi
ajouter l'objet au canevas SANS recharger — jamais sélectionné/ouvert
dans le panneau de propriétés pour autant (ça, voir
onSceneObjectMouseDown, reste un rechargement pour l'instant). La liste
"🧩 Collision" n'est PAS incluse ici : addSceneObject() la redemande
ensuite à routes/scenes/scene_collision_rule_objects.py (même endpoint
que pour un changement de rôle/nom, ou une suppression) plutôt que de
dupliquer ici la logique de build_collision_rule_objects()."""
kind = request.form.get("kind", "personnage")
forge_character = request.form.get("forge_character")
background_slug = request.form.get("background_slug")
@@ -21,7 +34,22 @@ def scene_object_add(slug, screen_id):
new_id = screens.add_scene_object(
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
)
if request.headers.get("X-Requested-With") == "fetch":
obj = screens.get_scene_object(slug, new_id)
return jsonify({
"id": new_id, "kind": obj["kind"],
"x": obj["x"], "y": obj["y"], "width": obj["width"], "height": obj["height"],
"rendered_html": screens.render_scene_object(obj),
# Relance l'aperçu animé tout de suite (voir personnage-preview.js
# ::restartPersonnageIdlePreview) — sans ça, un personnage
# fraîchement posé restait figé sur sa première frame jusqu'au
# prochain rechargement de page (qui n'arrive plus jamais, voir
# addSceneObject() côté JS).
"animations": screens.resolve_personnage_animations(obj) if obj["kind"] == "personnage" else None,
})
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
# Conservé pour tout appelant non-JS (aucun connu actuellement, mais
# ne coûte rien à garder en secours).
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))
+16 -1
View File
@@ -1,4 +1,4 @@
from flask import redirect, url_for
from flask import redirect, request, url_for, jsonify
import screens
@@ -7,9 +7,24 @@ from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/delete", methods=["POST"])
def scene_object_delete(slug, object_id):
"""Supprime un objet de scène — bouton 🗑️ du panneau de propriétés
(voir templates/scene_edit.html), toujours pour l'objet SÉLECTIONNÉ (le
seul dont ce bouton est affiché).
Appelée en AJAX (deleteSceneObject(), static/js/scenes/scene-editor.js)
plutôt qu'en soumission de formulaire classique — sinon même
"rechargé en douceur" par pjax.js (qui intercepte tout <form>, voir son
commentaire d'en-tête), ça remplaçait TOUT <main> (canevas + onglets
Collision/Quêtes/Événements), perceptible comme un "vidage" de
l'éditeur le temps du remplacement."""
obj = screens.get_scene_object(slug, object_id)
if not obj:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False}), 404
return redirect(url_for("index"))
screen_id = obj["scene_id"]
screens.delete_scene_object(slug, object_id)
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True, "id": object_id})
# Secours pour tout appelant non-JS (aucun connu actuellement).
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
@@ -0,0 +1,14 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/dialogue-box-style", methods=["POST"])
def scene_object_dialogue_box_style(slug, object_id):
body = request.get_json(silent=True) or {}
style = screens.set_scene_object_dialogue_box_style(slug, object_id, body.get("style"))
if style is None:
return jsonify({"error": "objet introuvable"}), 404
return jsonify({"style": style})
+12
View File
@@ -0,0 +1,12 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-objects/<int:object_id>/name", methods=["POST"])
def scene_object_name(slug, object_id):
data = request.get_json(force=True)
screens.set_scene_object_name(slug, object_id, data.get("name"))
return jsonify({"ok": True})
+20
View File
@@ -0,0 +1,20 @@
from flask import jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/scene-object-names", methods=["GET"])
def scene_object_names_list(slug):
"""Noms d'objets de scène à jour, pour repeupler le sélecteur "qui
parle" de l'éditeur de dialogue (voir SCENE_OBJECT_NAMES_JSON,
templates/scene_edit.html, questSpeakerOptions() dans static/js/
quests/quest-editor.js) — cette variable n'est posée qu'une fois au
chargement complet de la page (HORS de #builder3, jamais rafraîchie
par une sélection d'objet sans rechargement, voir goToSelected()) :
sans cet endpoint, renommer un personnage puis rouvrir la modale de
dialogue dans la MÊME session ne faisait jamais apparaître son nouveau
nom, tant qu'aucun vrai rechargement de page n'avait eu lieu entre
temps — appelée en AJAX depuis saveSceneObjectName()."""
return jsonify(screens.list_named_scene_object_names(slug))
+1 -1
View File
@@ -1 +1 @@
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect, screen_set_background_music
from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_background_music, screen_set_scene_size
+4 -124
View File
@@ -1,6 +1,4 @@
import json
from flask import render_template, request, abort, g
from flask import abort
import db
import screens
@@ -15,124 +13,6 @@ def screen_edit(slug, screen_id):
screen = screens.get_screen(slug, screen_id)
if not screen:
abort(404)
if screen["kind"] == "jeu_2d":
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) :
# le type d'écran ("document" ou "jeu_2d") est une propriété de
# L'ÉCRAN, pas du projet — même route/endpoint pour les deux,
# dispatch ici selon l'écran demandé — tout le reste de cette
# fonction ne concerne QUE l'éditeur document (comportement
# inchangé pour un écran "document").
return render_scene_edit(slug, screen_id, game)
elements = screens.list_elements(slug, screen_id)
all_screens = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
selected_id = request.args.get("selected", type=int)
selected_controls = None
personnage_data = None
if selected_id:
selected_el = next((e for e in elements if e["id"] == selected_id), None)
if selected_el:
template_definition_id = None
if screen.get("is_template"):
owning_type = screens.get_element_type_by_template_screen(slug, screen_id)
if owning_type:
template_definition_id = owning_type.get("bound_definition_id")
selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id)
if selected_el.get("widget") == "personnage":
# Phase 8 — panneau de propriétés sur-mesure (custom_panel,
# voir registry.py) : pas de controls génériques à afficher,
# juste la source (Forge/personnalisée) et son contenu.
personnage_data = screens.resolve_personnage_data(selected_el)
children_of = {}
for el in elements:
if el.get("parent_id"):
children_of.setdefault(el["parent_id"], []).append(el)
top_level_elements = [el for el in elements if not el.get("parent_id")]
elements_by_id = {el["id"]: el for el in elements}
definitions = db.list_definitions(slug)
definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions}
# Phase 4 — nodeLabel() (static/js/screen_edit/flow-editor.js) affiche
# le nom de l'objet visé par un nœud "Ajouter une ligne" ; DEFINITIONS_DATA
# (ci-dessus) ne porte que lignes/champs, jamais le nom lui-même.
definitions_names = {d["id"]: d["name"] for d in definitions}
# list_flow_blocks() rattache paresseusement les nœuds "orphelins"
# (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" —
# DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes
# garderait en mémoire un block_id périmé (None) pour ces nœuds.
flow_blocks = screens.list_flow_blocks(slug, screen_id)
flow_nodes = screens.list_flow_nodes(slug, screen_id)
flow_edges = screens.list_flow_edges(slug, screen_id)
elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements}
screens_map = {s["id"]: s["name"] for s in all_screens}
# Phase 8 — sélecteur d'animation à miniatures (nœud de flow "Jouer une
# animation" / clip de Timeline "sprite") : scopé aux SEULES animations
# de l'élément personnage réellement ciblé, jamais un catalogue global.
element_animations_map = {
el["id"]: screens.resolve_personnage_animations(el) for el in elements if el.get("widget") == "personnage"
}
animation_clips = screens.list_animation_clips(slug, screen_id)
global_variables = db.list_global_variables(slug)
custom_events = db.list_custom_events(slug)
custom_event_usages = screens.list_custom_event_usages(slug)
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels)
# Sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY) : réservés au
# rôle admin (licence CraftPix, voir core/sprite_gate.py et le plan
# "Bibliothèque de sprites animaux CraftPix") — un compte "user" ne
# doit même pas les voir dans la galerie.
sprite_library = screens.SPRITE_LIBRARY if g.current_user["role"] == "admin" else screens.PUBLIC_SPRITE_LIBRARY
sprite_library_families = screens.sprite_gallery_families(sprite_library)
return render_template(
"screen_edit.html",
game=game, screen=screen, elements=elements, top_level_elements=top_level_elements,
children_of=children_of, container_widgets=screens.CONTAINER_WIDGETS,
elements_by_id=elements_by_id,
all_screens=all_screens,
element_types=element_types, widgets=screens.WIDGETS,
trigger_labels=screens.TRIGGER_LABELS, action_type_labels=screens.ACTION_TYPE_LABELS,
element_action_properties=screens.ELEMENT_ACTION_PROPERTIES,
element_action_property_labels=screens.ELEMENT_ACTION_PROPERTY_LABELS,
element_visibility_values=screens.ELEMENT_VISIBILITY_VALUES,
element_visibility_labels=screens.ELEMENT_VISIBILITY_LABELS,
element_surbrillance_values=screens.ELEMENT_SURBRILLANCE_VALUES,
element_desactive_values=screens.ELEMENT_DESACTIVE_VALUES,
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
sprite_library_json=json.dumps(sprite_library),
personnage_data=personnage_data,
element_animations_map_json=json.dumps(element_animations_map),
data_operations=screens.DATA_OPERATIONS,
data_operation_labels=screens.DATA_OPERATION_LABELS,
score_status_choices=db.SCORE_STATUS_CHOICES,
score_status_labels_json=json.dumps(db.SCORE_STATUS_LABELS),
definitions=definitions, definitions_json=json.dumps(definitions_data),
selected_id=selected_id, selected_controls=selected_controls,
flow_nodes=flow_nodes, flow_edges=flow_edges,
flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges),
trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS,
elements_labels_json=json.dumps(elements_labels), screens_map_json=json.dumps(screens_map),
action_type_labels_json=json.dumps(screens.ACTION_TYPE_LABELS),
element_action_property_labels_json=json.dumps(screens.ELEMENT_ACTION_PROPERTY_LABELS),
element_visibility_labels_json=json.dumps(screens.ELEMENT_VISIBILITY_LABELS),
element_surbrillance_labels_json=json.dumps(screens.ELEMENT_SURBRILLANCE_LABELS),
element_desactive_labels_json=json.dumps(screens.ELEMENT_DESACTIVE_LABELS),
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
data_operation_labels_json=json.dumps(screens.DATA_OPERATION_LABELS),
condition_operator_labels_json=json.dumps(screens.CONDITION_OPERATOR_LABELS),
trigger_labels_json=json.dumps(screens.TRIGGER_LABELS),
animation_clips=animation_clips, animation_clips_json=json.dumps(animation_clips),
animate_css_catalog=screens.ANIMATE_CSS_CATALOG,
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
fontawesome_icons=screens.FONTAWESOME_ICONS,
global_variables=global_variables,
custom_events=custom_events, custom_event_usages=custom_event_usages,
custom_events_map_json=json.dumps(custom_events_map),
blocks_view=blocks_view,
flow_blocks_json=json.dumps(flow_blocks),
definitions_names_json=json.dumps(definitions_names),
)
# Un seul moteur d'écran désormais (jeu_2d) — voir
# screens/screens_repo/ensure_schema.py.
return render_scene_edit(slug, screen_id, game)
-17
View File
@@ -1,17 +0,0 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/screens/<int:screen_id>/aspect", methods=["POST"])
def screen_set_aspect(slug, screen_id):
"""Mémorise le format d'aperçu (Portrait/Paysage/Carré) choisi pour cet
écran, en AJAX — sans recharger la page, pour ne jamais perdre ce choix
au prochain ajout/suppression d'élément ou changement de sélection (qui
redemandent le panneau au serveur)."""
body = request.get_json(silent=True) or {}
aspect = body.get("aspect", "")
screens.set_screen_aspect(slug, screen_id, aspect)
return jsonify({"ok": True})
+20
View File
@@ -0,0 +1,20 @@
from flask import request, jsonify
import screens
from core.flask_app import app
@app.route("/game/<slug>/screens/<int:screen_id>/scene-size", methods=["POST"])
def screen_set_scene_size(slug, screen_id):
""""📐 Agrandir la caméra à la zone visible" (voir templates/
scene_edit.html) — redimensionne la scène/caméra elle-même à la
taille du "monde" affiché (world_width/world_height, voir
routes/scenes/scene_edit_view.py), calculée et postée par le client."""
body = request.get_json(silent=True) or {}
try:
width, height = int(body.get("width")), int(body.get("height"))
except (TypeError, ValueError):
return jsonify({"error": "largeur/hauteur invalides"}), 400
screens.set_scene_size(slug, screen_id, width, height)
return jsonify({"ok": True})
+3 -12
View File
@@ -9,18 +9,9 @@ from core.flask_app import app
@app.route("/game/<slug>/screens/new", methods=["POST"])
def screens_new(slug):
name = request.form.get("name", "").strip()
if db.is_restricted(slug):
# Dashboard simplifié (voir routes/games/game_dashboard.py) : pas
# de sélecteur de type dans son formulaire "+ Ajouter un écran" —
# imposé par le projet (jeu_2d pour un RPG, document pour les
# autres), même valeur pour TOUS ses écrans (voir
# db/games/game_type_catalog.py).
onboarding_type = db.get_onboarding_type(slug)
kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"]
else:
kind = request.form.get("kind", "document")
if kind not in ("document", "jeu_2d"):
kind = "document"
# Un seul moteur d'écran désormais (jeu_2d) — imposé à tout écran créé,
# quel que soit le projet (voir db/games/game_type_catalog.py).
kind = "jeu_2d"
if name:
screen_id = screens.create_screen(slug, name, kind=kind)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id))
+15 -67
View File
@@ -1,34 +1,15 @@
"""
screens.py — écrans de jeu : éléments posés en glisser-déposer sur un
écran, sans jamais toucher à un fichier HTML/CSS, enchaînement des écrans,
et actions déclenchées par clic ou soumission de formulaire.
Version 3 — inspirée d'Elementor / Bricks Builder : un catalogue fermé de
WIDGETS (Texte, Titre, Image, Bouton, Case à cocher, Répéteur de
données...), chacun avec ses propres réglages ("controls") : un réglage =
un champ, une case à cocher, un bouton radio ou un curseur — jamais de nom
de balise ni de nom de propriété CSS à taper. En coulisses, chaque widget
correspond à une vraie balise HTML avec de vrais attributs et un vrai
style CSS ; l'utilisateur ne les manipule jamais directement.
screens.py — écrans de jeu : un seul moteur d'écran, la scène 2D "jeu_2d"
(voir screens/scenes/) — objets posés en pixels, enchaînement des écrans,
et logique déclenchée par clic/collision/clavier/minuteur (voir
screens/flow/).
Tout est stocké dans la même base SQLite que le reste du jeu
(projects/<slug>/game.db) :
- _screens : un écran du jeu (accueil, formulaire, scène...)
- _element_types : le "catalogue" des éléments de jeu réutilisables
(Personnage, Outil, IA...) que l'utilisateur crée
- _screen_elements : chaque élément posé sur un écran (widget, position
et taille en POURCENTAGE de l'écran, contenu,
attributs et style — dérivés des réglages du
widget, jamais saisis en texte libre)
- _actions : ce qui se passe au clic / à la soumission d'un
élément (écran suivant, écran précédent, aller à
un écran précis, ne rien faire)
Un élément peut aussi être un "Répéteur de données" : au lieu d'un contenu
fixe, il est relié à un objet défini dans ce jeu (voir db.py) et affiche
automatiquement une ligne par donnée existante — par exemple, tous les
niveaux d'un parcours — à partir d'un modèle de texte avec des
{{nom_de_champ}}.
- _screens : un écran du jeu (une scène...)
- _scene_objects : chaque objet posé sur une scène (personnage,
décor, fond, widgets d'interface...)
- _flow_nodes/_flow_edges : le graphe de logique de chaque écran
Ce fichier est désormais un package (voir les sous-dossiers) organisé
selon "un fichier = une fonction, un dossier = une responsabilité" ; ce
@@ -37,20 +18,10 @@ utilisée par app.py et les templates, afin que `import screens` continue
de fonctionner à l'identique.
"""
from .widgets.registry import WIDGETS
from .widgets.container_widgets import CONTAINER_WIDGETS
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
from .widgets.widget_meta import widget_meta
from .widgets.controls_with_values import controls_with_values
from .widgets.data_definition_options import data_definition_options
from .data_definitions.data_definition_options import data_definition_options
from .labels.trigger_labels import TRIGGER_LABELS
from .labels.action_type_labels import ACTION_TYPE_LABELS
from .labels.element_action_properties import (
ELEMENT_ACTION_PROPERTIES, ELEMENT_ACTION_PROPERTY_LABELS,
ELEMENT_SURBRILLANCE_VALUES, ELEMENT_SURBRILLANCE_LABELS,
ELEMENT_DESACTIVE_VALUES, ELEMENT_DESACTIVE_LABELS,
ELEMENT_ORIENTATION_VALUES, ELEMENT_ORIENTATION_LABELS,
)
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
@@ -78,7 +49,6 @@ from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID,
)
from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for
from .rendering.personnage_data import resolve_personnage_data, resolve_personnage_animations, resolve_personnage_idle_frame
from .rendering.personnage_role import resolve_personnage_role, PERSONNAGE_ROLES, PERSONNAGE_ROLE_LABELS, DEFAULT_PERSONNAGE_ROLE
@@ -88,6 +58,8 @@ from .rendering.collision_rules import (
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
)
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
from .rendering.scene_object_names import list_named_scene_object_names
from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES
from .screens_repo.ensure_schema import ensure_schema
from .screens_repo.list_screens import list_screens
@@ -97,26 +69,13 @@ from .screens_repo.rename_screen import rename_screen
from .screens_repo.delete_screen import delete_screen
from .screens_repo.move_screen import move_screen
from .screens_repo.screens_referencing import screens_referencing
from .screens_repo.set_screen_aspect import set_screen_aspect
from .screens_repo.set_scene_size import set_scene_size
from .screens_repo.set_screen_background_music import set_screen_background_music
from .elements.list_elements import list_elements
from .elements.get_element import get_element
from .elements.add_element import add_element
from .elements.add_tab import add_tab
from .elements.update_element_geometry import update_element_geometry
from .elements.reorder_element import reorder_element
from .elements.move_element_to_container import move_element_to_container
from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element
from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element
from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_element
from .elements.set_personnage_data import set_personnage_data
from .scenes.ensure_scene_schema import ensure_scene_schema
from .scenes.add_scene_object import add_scene_object
from .scenes.list_scene_objects import list_scene_objects
from .scenes.resolve_scene_world_size import resolve_scene_world_size
from .scenes.get_scene_object import get_scene_object
from .scenes.update_scene_object_geometry import update_scene_object_geometry
from .scenes.delete_scene_object import delete_scene_object
@@ -125,21 +84,10 @@ from .scenes.set_scene_object_commands import set_scene_object_commands
from .scenes.set_scene_object_role import set_scene_object_role
from .scenes.set_scene_object_collision import set_scene_object_collision
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
from .scenes.set_scene_object_name import set_scene_object_name
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
from .scenes.render_scene_object import render_scene_object
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .elements.element_label import element_label
from .element_types.list_element_types import list_element_types
from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen
from .element_types.create_element_type import create_element_type
from .element_types.update_element_type import update_element_type
from .element_types.element_type_usage_count import element_type_usage_count
from .element_types.delete_element_type import delete_element_type
from .legacy_actions.list_actions import list_actions
from .legacy_actions.add_action import add_action
from .legacy_actions.get_action import get_action
from .legacy_actions.delete_action import delete_action
from .flow.ensure_flow_schema import ensure_flow_schema
from .flow.list_flow_nodes import list_flow_nodes
-1
View File
@@ -1 +0,0 @@
"""Catalogue d'éléments de jeu réutilisables (Personnage, Outil, IA...)."""
@@ -1,21 +0,0 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
from ..screens_repo.create_screen import create_screen
def create_element_type(slug, name, icon, bound_definition_id=None):
"""Un élément de jeu est un conteneur réutilisable : sa "recette" (les
éléments qu'il contient, imbriqués et stylés) vit dans un écran-modèle
caché, créé ici en même temps que l'entrée de catalogue — on le
remplit ensuite exactement comme le contenu de n'importe quel
conteneur, via l'éditeur d'écran normal pointé sur cet écran-modèle."""
ensure_schema(slug)
template_screen_id = create_screen(slug, "Modèle : " + name, is_template=True)
conn = db.connect(slug)
conn.execute(
"INSERT INTO _element_types (name, icon, template_screen_id, bound_definition_id) VALUES (?, ?, ?, ?)",
(name, icon or "🧩", template_screen_id, int(bound_definition_id) if bound_definition_id else None),
)
conn.commit()
conn.close()
@@ -1,15 +0,0 @@
import db
def delete_element_type(slug, element_type_id):
conn = db.connect(slug)
row = conn.execute(
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
).fetchone()
conn.execute("DELETE FROM _element_types WHERE id = ?", (element_type_id,))
if row and row["template_screen_id"]:
# Supprime aussi l'écran-modèle caché associé — son propre contenu
# (_screen_elements) part avec lui via ON DELETE CASCADE.
conn.execute("DELETE FROM _screens WHERE id = ?", (row["template_screen_id"],))
conn.commit()
conn.close()
@@ -1,10 +0,0 @@
import db
def element_type_usage_count(slug, element_type_id):
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE element_type_id = ?", (element_type_id,)
).fetchone()["c"]
conn.close()
return count
@@ -1,16 +0,0 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
def get_element_type_by_template_screen(slug, screen_id):
"""L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST
cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur
d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu
(bound_definition_id, réglé à la création — voir element_types.html)
sans devoir tout lister soi-même."""
ensure_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone()
conn.close()
return dict(row) if row else None
-16
View File
@@ -1,16 +0,0 @@
from .load_template_tree import _load_template_tree
def _is_overlay_only_element_type(slug, element_type_id):
"""True si le modèle d'un élément de jeu N'EST QU'une "Superposition /
boîte de dialogue" (un seul élément de premier niveau, de ce widget) —
utilisé à deux endroits qui doivent s'accorder : render_element_html.py
(court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui
resterait sinon visible en permanence) et element_style_filter.py (le
cadre .canvasElement/.playElement de cet exemplaire ne doit pas non
plus poser de z-index, pour la même raison qu'une superposition posée
directement — voir son propre commentaire)."""
template_tree = _load_template_tree(slug, element_type_id)
if not template_tree:
return False
return {c.get("widget") for c in template_tree["top"]} == {"superposition"}
@@ -1,19 +0,0 @@
import json
import db
from ..screens_repo.ensure_schema import ensure_schema
def list_element_types(slug):
ensure_schema(slug)
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _element_types ORDER BY id").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["default_style"] = json.loads(d["default_style"] or "{}")
d["default_attributes"] = json.loads(d["default_attributes"] or "{}")
result.append(d)
return result
@@ -1,49 +0,0 @@
import json
import db
def _load_template_tree(slug, element_type_id):
"""Charge le contenu (éléments de premier niveau + descendants) de
l'écran-modèle d'un élément de jeu, pour un rendu EN DIRECT (sans copier
quoi que ce soit en base) — utilisé quand un Répéteur de données affiche
cet élément de jeu comme modèle de ligne (voir _render_repeater) : on
relit et on rejoue son arbre à chaque ligne de données, avec les
{{champ}} de cette ligne. Renvoie None si l'élément de jeu n'a pas (ou
plus) d'écran-modèle valide."""
conn = db.connect(slug)
et_row = conn.execute(
"SELECT template_screen_id FROM _element_types WHERE id = ?", (element_type_id,)
).fetchone()
if not et_row or not et_row["template_screen_id"]:
conn.close()
return None
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(et_row["template_screen_id"],),
).fetchall()
conn.close()
elements = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
elements.append(d)
children_map = {}
for d in elements:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
top = [d for d in elements if not d.get("parent_id")]
if not top:
return None
# Astuce de rendu : ces éléments sont "de premier niveau" dans leur
# écran-modèle (parent_id NULL en base, donc positionnés en absolu par
# défaut), mais une fois le modèle réutilisé ailleurs (ligne de
# Répéteur), ils doivent se comporter comme des enfants en flux normal,
# pas comme des boîtes positionnées en absolu sur un écran plein. On leur
# donne donc un parent_id "factice" (une chaîne, jamais un vrai id) rien
# que pour ce rendu éphémère — _style_string ne teste que sa présence,
# jamais sa valeur exacte, et cette copie n'est jamais enregistrée.
for t in top:
t["parent_id"] = t.get("parent_id") or "template-root"
return {"top": top, "children_map": children_map}
@@ -1,13 +0,0 @@
import db
def update_element_type(slug, element_type_id, name=None, bound_definition_id=None):
conn = db.connect(slug)
if name:
conn.execute("UPDATE _element_types SET name = ? WHERE id = ?", (name, element_type_id))
conn.execute(
"UPDATE _element_types SET bound_definition_id = ? WHERE id = ?",
(int(bound_definition_id) if bound_definition_id else None, element_type_id),
)
conn.commit()
conn.close()
-1
View File
@@ -1 +0,0 @@
"""Éléments posés sur un écran (_screen_elements)."""
-87
View File
@@ -1,87 +0,0 @@
import json
import db
from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
"""parent_id : si fourni, l'élément est posé PHYSIQUEMENT à l'intérieur
de cet élément parent (qui doit être un conteneur/répéteur/groupe de
champs) plutôt que directement sur l'écran — sa position/taille ne sert
alors plus (il suit le flux du parent), mais on garde des valeurs par
défaut pour rester cohérent si jamais il en sort un jour.
element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
comme n'importe quel widget), qui garde juste une référence vers cet
élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
copié en base : il est rechargé EN DIRECT à chaque affichage (voir
render_element_html.py), exactement comme un modèle de ligne de
Répéteur (voir _load_template_tree/_render_repeater). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un — au prix de ne plus pouvoir personnaliser
individuellement le CONTENU d'un exemplaire précis (sa position/taille
sur l'écran, elles, restent propres à chaque exemplaire, comme
d'habitude)."""
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
if element_type_id:
types = {t["id"]: t for t in list_element_types(slug)}
et = types.get(int(element_type_id))
if et:
widget_key = et.get("default_widget") or widget_key
meta = widget_meta(widget_key)
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
# création (ex: une image démarre avec "cover" pour se recadrer
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
# de dialogue" (render_element_html.py décide alors, au rendu, de
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
# démarre masqué par défaut, exactement comme une superposition
# posée directement (voir default_style_for_widget.py) — sinon un
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
# sa pose, avant même qu'une action ne l'affiche.
style["display"] = "none"
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchone()["m"]
# Décalage en cascade : sans ça, chaque nouvel élément posé DIRECTEMENT
# SUR L'ÉCRAN atterrit exactement au même endroit (10%, 10%) que le
# précédent et le recouvre entièrement — ce qui peut donner l'impression
# trompeuse qu'un "élément indésirable" vient d'apparaître et d'en
# cacher un autre (le plus souvent après une confusion entre "Ajouter un
# élément sur l'écran" et "Ajouter DANS ce conteneur", qui posent tous
# les deux un nouvel élément mais pas au même endroit dans l'arbre).
x = y = 10
if not parent_id:
top_level_count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE screen_id = ? AND parent_id IS NULL",
(screen_id,),
).fetchone()["c"]
offset = (top_level_count % 8) * 4
x = y = 10 + offset
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, content, attributes, x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
screen_id, parent_id or None, widget_key, element_type_id or None, content, json.dumps(attributes),
x, y, width, height, (max_z or 0) + 1,
json.dumps(style),
),
)
element_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return element_id
-19
View File
@@ -1,19 +0,0 @@
import db
from .add_element import add_element
from .rename_element import rename_element
def add_tab(slug, onglets_id, screen_id):
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
modifiable ensuite comme celui de n'importe quel élément."""
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
).fetchone()["c"]
conn.close()
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
rename_element(slug, tab_id, f"Onglet {count + 1}")
return tab_id
-50
View File
@@ -1,50 +0,0 @@
import db
from ..animations.ensure_animation_schema import ensure_animation_schema
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def delete_element(slug, element_id):
ensure_flow_schema(slug)
ensure_animation_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
# élément"/action "Modifier cet élément"/condition de collision — voir
# ci-dessous...) ou une ancienne action du système _actions (conservé
# pour compatibilité) qui référence l'élément supprimé OU L'UN DE SES
# DESCENDANTS n'a plus aucun sens une fois l'élément disparu — et ces
# colonnes (trigger_element_id/target_element_id) n'ont volontairement
# PAS de ON DELETE CASCADE (un élément ne doit pas pouvoir être
# supprimé "par erreur" en cascade depuis un nœud de logique qu'on
# modifie). Sans ce nettoyage préalable, PRAGMA foreign_keys=ON (voir
# db/connection.py) fait échouer la suppression elle-même avec
# "FOREIGN KEY constraint failed". Voir element_delete_impact.py pour
# prévenir l'utilisateur AVANT qu'il confirme, plutôt que de supprimer
# ces nœuds en silence.
#
# cond_element_a/cond_element_b (Phase 5, condition de collision) : ni
# contrainte FK ni ON DELETE CASCADE (voir ensure_flow_schema.py), mais
# un nœud qui compare le rectangle de CET élément à un autre n'a pas
# plus de sens que si c'était trigger_element_id/target_element_id —
# supprimé ici pour la même raison, pas parce que la base l'exigerait.
conn.execute(
f"""DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
OR target_element_id IN ({placeholders})
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
ids + ids + ids + ids,
)
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
# _animation_clips.element_id n'a plus de contrainte FK (voir
# ensure_animation_schema.py — réutilisé aussi par les objets de scène
# jeu_2d) : nettoyage manuel, même raison que ci-dessus.
conn.execute(f"DELETE FROM _animation_clips WHERE element_id IN ({placeholders})", ids)
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
conn.commit()
conn.close()
-60
View File
@@ -1,60 +0,0 @@
import db
def duplicate_element(slug, element_id):
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
de l'arborescence n'a pas à faire choisir où poser la copie, elle
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
sélectionner ensuite), ou None si l'élément n'existe pas."""
conn = db.connect(slug)
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not root_row:
conn.close()
return None
root = dict(root_row)
source = [
dict(r) for r in conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
).fetchall()
]
conn.close()
def copy_one(src, new_parent_id, is_root):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
).fetchone()["m"]
x, y = src["x"], src["y"]
if is_root and not new_parent_id:
# Petit décalage pour que la copie ne recouvre pas exactement
# l'original (qui serait alors invisible en dessous).
x = min(100, (x or 0) + 4)
y = min(100, (y or 0) + 4)
name = src.get("name")
if is_root and name:
name = f"{name} (copie)"
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
src["content"], src["attributes"], x, y, src["width"], src["height"],
(max_z or 0) + 1, src["style"],
),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
def walk(src_parent_id, new_parent_id):
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
new_child_id = copy_one(child, new_parent_id, is_root=False)
walk(child["id"], new_child_id)
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
walk(root["id"], new_root_id)
return new_root_id
@@ -1,23 +0,0 @@
def element_descendant_ids(conn, screen_id, root_id):
"""Id de cet élément et de TOUS ses descendants — ceux qu'une
suppression en cascade (parent_id ON DELETE CASCADE, voir
ensure_schema.py) supprime automatiquement en même temps que lui.
Partagé entre delete_element.py (nettoyage des références de la
Logique de la scène avant suppression) et flow_nodes_referencing_
element.py (pour prévenir l'utilisateur AVANT qu'il confirme la
suppression, voir routes/elements/element_delete_impact.py)."""
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchall()
children_of = {}
for r in rows:
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
ids = [root_id]
stack = [root_id]
while stack:
current = stack.pop()
for child_id in children_of.get(current, []):
ids.append(child_id)
stack.append(child_id)
return ids
-10
View File
@@ -1,10 +0,0 @@
from ..widgets.registry import WIDGETS
def element_label(el, widgets_catalog=None):
"""Nom lisible d'un élément pour les listes/menus : son nom personnalisé
s'il en a un, sinon le libellé de son widget (ex: "Image")."""
catalog = widgets_catalog if widgets_catalog is not None else WIDGETS
if el.get("name"):
return el["name"]
return catalog.get(el.get("widget"), {}).get("label", el.get("widget", "?"))
@@ -1,30 +0,0 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
from .element_descendant_ids import element_descendant_ids
def flow_nodes_referencing_element(slug, element_id):
"""Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet
élément", action "Modifier cet élément"/"Activer cet onglet", condition
de collision — Phase 5...) qui référencent cet élément OU L'UN DE SES
DESCENDANTS — donc qui seraient supprimés SI on supprimait cet élément
(voir delete_element.py). Utilisé pour prévenir l'utilisateur AVANT
qu'il confirme la suppression (voir la modale de confirmation dans
screen_edit.html), plutôt que de les supprimer en silence."""
ensure_flow_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return 0
ids = element_descendant_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
count = conn.execute(
f"""SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders})
OR target_element_id IN ({placeholders})
OR cond_element_a IN ({placeholders}) OR cond_element_b IN ({placeholders})""",
ids + ids + ids + ids,
).fetchone()["c"]
conn.close()
return count
-15
View File
@@ -1,15 +0,0 @@
import json
import db
def get_element(slug, element_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
return d
-95
View File
@@ -1,95 +0,0 @@
import json
import db
from ..rendering.render_element_html import render_element_html
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def list_elements(slug, screen_id, enforce_visibility=False, player_id=db.PLAYER_SHARED):
"""Renvoie TOUS les éléments de l'écran, à plat (parent_id indique
l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE
élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" :
celui d'un conteneur/répéteur/groupe de champs inclut, en le générant
récursivement, le HTML de tout ce qui est posé à l'intérieur — un
élément imbriqué s'y retrouve donc DEUX FOIS (une fois dans le
rendered_html de son ancêtre de premier niveau, utilisé pour le rendu
HTML initial de la page ; une fois dans le sien propre) : c'est ce
dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/
refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué
(ex. un panneau de détail posé dans un conteneur) sans reconstruire tout
son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce
changement, seuls les éléments de premier niveau recevaient un
rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui
retombait donc toujours sur la régénération de son ancêtre de premier
niveau, quelle que soit la finesse du ciblage côté JS.
enforce_visibility : True uniquement pour le mode jouable (voir
full_game_payload.py) — applique la "condition de visibilité" de chaque
élément (voir visibility_condition.py). Toujours False pour l'éditeur,
qui doit continuer à montrer/sélectionner un élément même si sa
condition serait actuellement fausse.
player_id (état par joueur, Phase 1) : posé dans ctx["_forge_player_id"]
pour tout le rendu en aval (Répéteur/Jauge/Donnée liée/condition de
visibilité) — quel joueur consulte cet écran, pour qu'un objet/une
variable per_player affiche SA valeur (voir db/global_vars/,
db/rows/)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id", (screen_id,)
).fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d["style"] = json.loads(d["style"] or "{}")
d["attributes"] = json.loads(d["attributes"] or "{}")
result.append(d)
# Descendant d'un exemplaire d'élément de jeu (element_type_id réglé sur
# un ancêtre) : un ancien enfant copié en base par l'ex-instantiate_
# template_tree (mécanisme retiré, voir add_element.py) — son contenu
# vient désormais TOUJOURS en direct de l'écran-modèle au moment du
# rendu (voir render_element_html.py/_render_element_type_children),
# jamais de ces lignes, qui peuvent encore traîner en base pour un
# exemplaire posé avant ce changement. Exclu ici pour ne plus jamais
# apparaître sélectionnable dans l'arborescence de l'éditeur — sinon
# ses réglages, plus jamais synchronisés avec le modèle, donnaient
# l'impression trompeuse que "les propriétés ne sont pas prises en
# compte" (une taille de texte modifiée dans le modèle ne se reflétant
# jamais sur cette ligne fantôme, toujours sélectionnable à l'écran).
by_id = {d["id"]: d for d in result}
def _has_linked_ancestor(d):
seen = set()
parent_id = d.get("parent_id")
while parent_id and parent_id not in seen:
seen.add(parent_id)
parent = by_id.get(parent_id)
if not parent:
return False
if parent.get("element_type_id"):
return True
parent_id = parent.get("parent_id")
return False
result = [d for d in result if not _has_linked_ancestor(d)]
children_map = {}
for d in result:
if d.get("parent_id"):
children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True, "_forge_player_id": player_id} if enforce_visibility else None
for d in result:
# Un exemplaire de premier niveau d'un élément de jeu dont le
# modèle N'EST QU'une superposition : son cadre de positionnement
# (.canvasElement/.playElement, voir filters/element_style_filter.py)
# ne doit pas non plus poser de z-index, pour la même raison qu'une
# superposition posée directement (sinon elle crée un contexte
# d'empilement CSS qui piège le z-index:9999 de la superposition
# rendue à l'intérieur — voir element_style_filter.py).
if d.get("element_type_id") and not d.get("parent_id"):
d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"])
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
return result
@@ -1,62 +0,0 @@
import db
from ..widgets.container_widgets import CONTAINER_WIDGETS
def move_element_to_container(slug, element_id, new_parent_id):
"""Déplace element_id pour qu'il devienne un enfant de new_parent_id
(glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en
DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme
d'habitude par glisser-déposer entre frères et sœurs (voir
reorder_element.py). Refuse silencieusement (ne touche à rien) si :
new_parent_id n'est pas un widget conteneur, si c'est element_id
lui-même, ou si c'est un de ses propres descendants (ça créerait un
cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)",
(element_id, new_parent_id),
).fetchall()
by_id = {r["id"]: r for r in rows}
el = by_id.get(element_id)
target = by_id.get(new_parent_id)
if not el or not target or el["screen_id"] != target["screen_id"]:
conn.close()
return
if target["widget"] not in CONTAINER_WIDGETS:
conn.close()
return
if new_parent_id == element_id:
conn.close()
return
# Cycle : new_parent_id ne doit pas être element_id, ni un descendant de
# element_id (direct ou indirect) — sinon element_id finirait posé à
# l'intérieur de sa propre arborescence.
children_of = {}
for r in conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchall():
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
stack = list(children_of.get(element_id, []))
descendants = set()
while stack:
node = stack.pop()
if node in descendants:
continue
descendants.add(node)
stack.extend(children_of.get(node, []))
if new_parent_id in descendants:
conn.close()
return
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchone()["m"]
conn.execute(
"UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?",
(new_parent_id, (max_z or 0) + 1, element_id),
)
conn.commit()
conn.close()
-8
View File
@@ -1,8 +0,0 @@
import db
def rename_element(slug, element_id, name):
conn = db.connect(slug)
conn.execute("UPDATE _screen_elements SET name = ? WHERE id = ?", ((name or "").strip() or None, element_id))
conn.commit()
conn.close()
-37
View File
@@ -1,37 +0,0 @@
import db
def reorder_element(slug, screen_id, element_id, target_id, position):
"""Déplace element_id juste avant ("before") ou juste après ("after")
target_id, DANS LE MÊME CONTENEUR (les deux doivent avoir le même
parent_id — sinon on ne touche à rien, pour ne jamais reparenter par
erreur via le glisser-déposer de l'arborescence). L'ordre est porté par
z_index, partagé par tous les éléments de l'écran (pas juste les frères
et sœurs) : on renumérote donc l'écran entier à partir de l'ordre actuel
(z_index, id) pour rester cohérent partout, pas seulement dans ce
conteneur."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ? ORDER BY z_index, id",
(screen_id,),
).fetchall()
by_id = {r["id"]: r["parent_id"] for r in rows}
if element_id not in by_id or target_id not in by_id:
conn.close()
return
if by_id[element_id] != by_id[target_id]:
conn.close()
return
order = [r["id"] for r in rows]
order.remove(element_id)
target_index = order.index(target_id)
insert_at = target_index + 1 if position == "after" else target_index
order.insert(insert_at, element_id)
conn.executemany(
"UPDATE _screen_elements SET z_index = ? WHERE id = ?",
[(i + 1, eid) for i, eid in enumerate(order)],
)
conn.commit()
conn.close()
-227
View File
@@ -1,227 +0,0 @@
import json
import db
from ..widgets.widget_meta import widget_meta
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from ..clause_list_codec import encode_clauses
from .get_element import get_element
def _hex_to_rgb(hex_color):
h = (hex_color or "").lstrip("#")
if len(h) != 6:
return (0, 0, 0)
try:
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
return (0, 0, 0)
def _is_default_value(control, raw_value):
"""Compare la valeur reçue du formulaire à la valeur par défaut du
réglage (en tenant compte de l'unité ajoutée pour les sliders), pour
savoir si le champ a simplement été renvoyé tel quel sans être
personnalisé."""
default = control.get("default", "")
if control["type"] == "slider":
unit = control.get("unit", "")
default = f"{default}{unit}" if default not in (None, "") else ""
return str(raw_value) == str(default)
def save_element_controls(slug, element_id, form):
"""Lit, pour chaque réglage du widget de cet élément, la valeur envoyée
par le panneau de propriétés (un seul formulaire, un champ par réglage)
et met à jour content / attributs / style en conséquence."""
el = get_element(slug, element_id)
if not el:
return
meta = widget_meta(el["widget"])
content = el["content"]
attributes = dict(el["attributes"])
style = dict(el["style"])
toggle_groups = {} # propriété CSS -> ensemble des "tokens" cochés (ex: text-decoration)
for control in meta.get("controls", []):
ctype = control["type"]
field_name = "ctrl_" + control["key"]
if ctype == "toggle_token":
_kind, name = control["target"].split(":", 1)
toggle_groups.setdefault(name, set())
if form.get(field_name) is not None:
toggle_groups[name].add(control["token"])
continue
if ctype == "clause_list":
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
# "_valeur[]"), reconstituées ici en une liste de clauses par
# position (zip) — même principe que field_name[]/field_type[]
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
champs = form.getlist(field_name + "_champ[]")
operateurs = form.getlist(field_name + "_operateur[]")
valeurs = form.getlist(field_name + "_valeur[]")
clauses = [
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
"valeur": valeurs[i] if i < len(valeurs) else ""}
for i, champ in enumerate(champs)
]
encoded = encode_clauses(clauses)
if encoded:
attributes[control["clauses_attr"]] = encoded
else:
attributes.pop(control["clauses_attr"], None)
combinateur = form.get(field_name + "_combinateur") or "et"
if combinateur == "ou":
attributes[control["combinator_attr"]] = combinateur
else:
attributes.pop(control["combinator_attr"], None)
continue
if ctype == "preset":
chosen = form.get(field_name, control.get("default"))
opt = next((o for o in control["options"] if o[0] == chosen), None)
if opt:
for k, v in opt[2].items():
if v in (None, ""):
style.pop(k, None)
else:
style[k] = v
if (
control["key"] == "visibilite" and chosen == "visible"
and el["widget"] not in LAYOUT_CAPABLE_WIDGETS
):
# "Visible" ne touche volontairement pas "display" (voir
# visibility_control.py) : sur un widget qui A un
# réglage "Disposition interne" (LAYOUT_CONTROLS,
# traité avant celui-ci sur le MÊME enregistrement,
# voir widget_meta.py), ce contrôle réaffirme lui-même
# un "display" non-"none" (flex/etc), donc "Visible" n'a
# rien à faire. Mais un widget SANS ce contrôle (ex.
# "superposition") n'a PERSONNE d'autre pour l'effacer :
# un "display:none" laissé par un "Masqué" précédent (ou
# par le réglage par défaut à la création, voir
# default_style_for_widget.py) restait donc bloqué
# masqué pour toujours, quel que soit le nombre de fois
# où "Visible" était ensuite choisi.
style.pop("display", None)
continue
if ctype == "scale":
try:
val = float(form.get(field_name, control.get("default", 1)))
except (TypeError, ValueError):
val = 1.0
if abs(val - 1) < 1e-6:
style.pop("transform", None)
else:
style["transform"] = f"scale({val})"
continue
if ctype == "size":
# Un seul champ CSS ("width"/"height") mais deux entrées de
# formulaire (valeur + unité) — voir size_override_controls.py
# pour pourquoi ce n'est PAS deux contrôles slider séparés.
_kind, name = control["target"].split(":", 1)
try:
num = float(form.get(field_name + "_value") or 0)
except (TypeError, ValueError):
num = 0.0
unit = form.get(field_name + "_unit", "%")
if unit not in ("px", "%"):
unit = "%"
if num <= 0:
style.pop(name, None)
else:
num_str = str(int(num)) if num == int(num) else str(num)
style[name] = f"{num_str}{unit}"
continue
if ctype == "shadow":
# 6 entrées de formulaire (décalage X/Y, flou, étendue, couleur,
# opacité — voir c_shadow.py) combinées en une seule valeur CSS
# "box-shadow" (couleur+opacité fusionnées en rgba(), un
# <input type="color"> seul ne portant pas de canal alpha).
_kind, name = control["target"].split(":", 1)
defaults = control.get("default", {})
def _num(suffix, fallback):
try:
return float(form.get(field_name + suffix, fallback))
except (TypeError, ValueError):
return fallback
x = _num("_x", defaults.get("x", 0))
y = _num("_y", defaults.get("y", 4))
blur = _num("_blur", defaults.get("blur", 12))
spread = _num("_spread", defaults.get("spread", 0))
opacity = _num("_opacity", defaults.get("opacity", 0.4))
color = form.get(field_name + "_color") or defaults.get("color", "#000000")
if x == 0 and y == 0 and blur == 0 and spread == 0:
style.pop(name, None)
else:
r, g, b = _hex_to_rgb(color)
def _px(n):
return str(int(n)) if n == int(n) else str(n)
style[name] = f"{_px(x)}px {_px(y)}px {_px(blur)}px {_px(spread)}px rgba({r}, {g}, {b}, {opacity})"
continue
target = control["target"]
if ctype == "checkbox":
checked = form.get(field_name) is not None
raw_value = control["true_value"] if checked else control["false_value"]
else:
raw_value = form.get(field_name, control.get("default", ""))
if ctype == "slider":
unit = control.get("unit", "")
raw_value = f"{raw_value}{unit}" if raw_value not in (None, "") else ""
if target == "content":
content = raw_value
else:
kind, name = target.split(":", 1)
if kind == "class":
# "class:xxx" : un jeton de classe CSS externe (ex. un
# modificateur Bulma) parmi d'autres, chacun mémorisé sous sa
# propre clé "_class_xxx" pour ne pas s'écraser entre eux —
# voir _visible_attrs.py qui les rassemble avec la classe de
# base du widget (fixed_attrs) au moment du rendu.
store, name = attributes, "_class_" + name
else:
store = attributes if kind == "attr" else style
if raw_value in (None, ""):
store.pop(name, None)
elif ctype != "checkbox" and name not in store and _is_default_value(control, raw_value):
# Ce réglage n'a jamais été personnalisé (la clé est absente
# du style/des attributs stockés) et la valeur reçue est
# simplement celle par défaut affichée dans le champ (un
# <input type="color"> ou un slider a TOUJOURS une valeur à
# l'écran, même quand rien n'a été choisi). Comme le
# panneau de propriétés envoie TOUS les réglages du widget
# en un seul formulaire, sauvegarder n'importe quel autre
# champ (ex. changer la disposition) écrirait sinon cette
# valeur par défaut "en dur" dans l'élément — ce qui faisait
# apparaître par exemple une couleur de fond qui n'avait
# jamais été demandée. On laisse donc ce réglage "non
# défini" tant qu'il n'a pas été explicitement changé.
pass
else:
store[name] = raw_value
for prop, tokens in toggle_groups.items():
if tokens:
style[prop] = " ".join(sorted(tokens))
else:
style.pop(prop, None)
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET content = ?, attributes = ?, style = ? WHERE id = ?",
(content, json.dumps(attributes), json.dumps(style), element_id),
)
conn.commit()
conn.close()
-20
View File
@@ -1,20 +0,0 @@
import json
import db
def set_personnage_data(slug, element_id, data):
"""Fusionne _personnage_data (voir screens/rendering/personnage_data.py)
dans les attributs déjà stockés de cet élément — remplace la source
(Forge/personnalisée) et son contenu (personnage choisi, ou animations
importées) en un seul appel, jamais les autres attributs de l'élément."""
conn = db.connect(slug)
row = conn.execute("SELECT attributes FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
attributes = json.loads(row["attributes"] or "{}")
attributes["_personnage_data"] = json.dumps(data)
conn.execute("UPDATE _screen_elements SET attributes = ? WHERE id = ?", (json.dumps(attributes), element_id))
conn.commit()
conn.close()
@@ -1,14 +0,0 @@
import db
def update_element_geometry(slug, element_id, x, y, width, height):
"""Utilisé par le glisser-déposer / redimensionnement (et par les champs
Position & taille du panneau de droite) — ne touche qu'à la position et
à la taille, rien d'autre."""
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET x = ?, y = ?, width = ?, height = ? WHERE id = ?",
(x, y, width, height, element_id),
)
conn.commit()
conn.close()
+2 -2
View File
@@ -11,7 +11,7 @@ def ensure_flow_schema(slug):
node_type TEXT NOT NULL,
x REAL NOT NULL DEFAULT 40,
y REAL NOT NULL DEFAULT 40,
trigger_element_id INTEGER REFERENCES _screen_elements(id),
trigger_element_id INTEGER,
trigger_event TEXT,
cond_definition_id INTEGER,
cond_row_id INTEGER,
@@ -21,7 +21,7 @@ def ensure_flow_schema(slug):
cond_value TEXT,
action_type TEXT,
target_screen_id INTEGER REFERENCES _screens(id),
target_element_id INTEGER REFERENCES _screen_elements(id),
target_element_id INTEGER,
element_property TEXT,
element_value TEXT,
target_definition_id INTEGER,
+7 -63
View File
@@ -1,66 +1,10 @@
# ---------- Actions : modifier un élément ----------
ELEMENT_ACTION_PROPERTIES = [
("visibilite", "Visibilité"),
# Voir TRIGGER_EVENTS ("survol"/"fin_survol") : c'est ce qui permet à un
# créateur de reconstruire l'ancien comportement "texte affiché au
# survol" (et bien d'autres) à la main dans la Logique de la scène —
# poser "Modifier un élément → Contenu" sur un déclencheur "Au survol"
# d'un AUTRE élément. La valeur est un texte littéral (pas encore de
# binding vers un champ d'objet ici — voir applyOpenRowBindings pour le
# mécanisme équivalent, propre à "Ouvrir la ligne cliquée").
("contenu", "Contenu — texte affiché"),
("bg_color", "Couleur de fond"),
("border_color", "Couleur de bordure"),
("width", "Largeur en %"),
("height", "Hauteur en %"),
# 3.3 (Confort) — surbrillance générique dynamique : un liseré qui
# clignote autour de l'élément, pour attirer l'attention du joueur vers
# ce qu'il doit toucher ensuite (ex. un mentor qui "montre" un bouton) —
# sans avoir à poser une bordure de couleur togglée à la main. Voir
# ELEMENT_SURBRILLANCE_VALUES et applyElementProperty() dans play.html.
("surbrillance", "Surbrillance — liseré clignotant"),
# 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
# ignore les clics, DISTINCT de "invisible" (qui garde sa place mais
# disparaît visuellement) — un bouton "Répondre" qui reste visible mais
# devient inerte une fois le mail traité, par exemple. Voir
# ELEMENT_DESACTIVE_VALUES et applyElementProperty() / bindClicks() dans
# play.html (pointer-events:none empêche aussi le déclencheur "Au clic"
# de se redéclencher tant que l'élément est désactivé).
("desactive", "Désactivé — grisé, ignore les clics"),
# Phase 5 (moteur) — déplacement d'élément : "absolu" fixe la position
# (en % du cadre, comme Largeur/Hauteur ci-dessus — les éléments sont
# déjà positionnés ainsi, voir filters/element_style_filter.py) ;
# "relatif" AJOUTE la valeur à la position actuelle plutôt que de
# l'écraser (voir applyElementProperty() dans static/js/play/actions.js)
# — combiné à un déclencheur clavier (Phase 3), ça permet un
# personnage/objet qui se déplace pas à pas à chaque appui de touche.
("pos_x", "Position horizontale en % (absolue)"),
("pos_y", "Position verticale en % (absolue)"),
("pos_x_relatif", "Déplacer horizontalement de... % (relatif)"),
("pos_y_relatif", "Déplacer verticalement de... % (relatif)"),
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
# l'élément (utile pour un personnage qui se déplace au clavier, voir
# applyElementProperty() dans static/js/play/actions.js — retourne
# l'élément en miroir plutôt que de nécessiter une 2e feuille de
# sprites "vue de dos").
("orientation", "Orientation — sens du personnage"),
]
ELEMENT_ACTION_PROPERTY_LABELS = dict(ELEMENT_ACTION_PROPERTIES)
ELEMENT_SURBRILLANCE_VALUES = [
("on", "Activer la surbrillance"),
("off", "Désactiver la surbrillance"),
("toggle", "Basculer activée / désactivée"),
]
ELEMENT_SURBRILLANCE_LABELS = dict(ELEMENT_SURBRILLANCE_VALUES)
ELEMENT_DESACTIVE_VALUES = [
("on", "Désactiver — griser, ignorer les clics"),
("off", "Réactiver"),
("toggle", "Basculer désactivé / réactivé"),
]
ELEMENT_DESACTIVE_LABELS = dict(ELEMENT_DESACTIVE_VALUES)
# ---------- Actions : orientation d'un objet de scène (jeu_2d) ----------
# Phase 7 (moteur) — personnage animé par sprites : quel sens regarde
# l'objet (utile pour un personnage qui se déplace au clavier, voir
# applyElementProperty() dans static/js/play/actions.js — retourne
# l'élément en miroir plutôt que de nécessiter une 2e feuille de sprites
# "vue de dos"). Repris par OBJECT_ACTION_PROPERTIES (screens/scenes/
# flow_palette.py) et routes/scenes/scene_edit_view.py.
ELEMENT_ORIENTATION_VALUES = [
("droite", "Faire face à droite"),
-2
View File
@@ -1,2 +0,0 @@
"""Ancien système d'actions par élément (_actions) — conservé pour ne rien
casser sur les jeux déjà créés, remplacé par le module flow/."""
-25
View File
@@ -1,25 +0,0 @@
import db
def add_action(
slug, element_id, trigger_event, action_type, target_screen_id=None,
target_element_id=None, element_property=None, element_value=None,
target_definition_id=None, target_row_id=None, target_field=None,
data_operation=None, data_value=None,
):
conn = db.connect(slug)
conn.execute(
"""INSERT INTO _actions
(element_id, trigger_event, action_type, target_screen_id,
target_element_id, element_property, element_value,
target_definition_id, target_row_id, target_field, data_operation, data_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
element_id, trigger_event, action_type, target_screen_id or None,
target_element_id or None, element_property or None, element_value or None,
target_definition_id or None, target_row_id or None, target_field or None,
data_operation or None, data_value or None,
),
)
conn.commit()
conn.close()
-8
View File
@@ -1,8 +0,0 @@
import db
def delete_action(slug, action_id):
conn = db.connect(slug)
conn.execute("DELETE FROM _actions WHERE id = ?", (action_id,))
conn.commit()
conn.close()
-8
View File
@@ -1,8 +0,0 @@
import db
def get_action(slug, action_id):
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _actions WHERE id = ?", (action_id,)).fetchone()
conn.close()
return dict(row) if row else None
-8
View File
@@ -1,8 +0,0 @@
import db
def list_actions(slug, element_id):
conn = db.connect(slug)
rows = conn.execute("SELECT * FROM _actions WHERE element_id = ? ORDER BY id", (element_id,)).fetchall()
conn.close()
return [dict(r) for r in rows]
+52 -85
View File
@@ -1,17 +1,15 @@
import db
from ..screens_repo.list_screens import list_screens
from ..elements.list_elements import list_elements
from ..flow.list_flow_nodes import list_flow_nodes
from ..flow.list_flow_edges import list_flow_edges
from ..animations.list_animation_clips import list_animation_clips
from ..element_types.list_element_types import list_element_types
from ..element_types.load_template_tree import _load_template_tree
from ..rendering.personnage_data import resolve_personnage_animations
from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings
from ..rendering.collision_rules import resolve_collision_rules
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
from ..scenes.list_scene_objects import list_scene_objects
from ..scenes.render_scene_object import render_scene_object
@@ -44,52 +42,43 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
flows = {}
animations = {}
for s in screens_:
if s["kind"] == "jeu_2d":
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches et son rôle déjà résolus
# (défauts comblés) pour que le runtime client n'ait
# aucune logique de repli à dupliquer — seul un
# personnage "joueur" est déplacé/suivi par la caméra.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
o["personnage_role"] = resolve_personnage_role(o)
# Boîte de collision (voir static/js/play/conditions.js::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# seulement "personnage" — un décor/fond peut aussi servir
# d'obstacle.
o["collision"] = resolve_collision_settings(o)
# Règles "à la collision/dans un périmètre -> action" (voir
# "🧩 Collision", templates/scene_edit.html, et
# static/js/play/collision-rules-controller.js pour leur
# exécution) — jamais sur "fond" (jamais une cible), ni sur
# le personnage "joueur" lui-même (ce sont TOUJOURS les
# AUTRES objets qui réagissent à SA présence, jamais
# l'inverse).
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
o["collision_rules"] = resolve_collision_rules(o)
payload_screens.append({**s, "elements": objects})
else:
elements = list_elements(slug, s["id"], enforce_visibility=True, player_id=player_id)
payload_screens.append({**s, "elements": elements})
objects = list_scene_objects(slug, s["id"])
for o in objects:
o["rendered_html"] = render_scene_object(o)
# Déplacement/animation automatiques (voir
# static/js/play/personnage-controller.js) : chaque
# personnage porte ses touches et son rôle déjà résolus
# (défauts comblés) pour que le runtime client n'ait
# aucune logique de repli à dupliquer — seul un
# personnage "joueur" est déplacé/suivi par la caméra.
if o["kind"] == "personnage":
o["personnage_commandes"] = resolve_personnage_commands(o)
o["personnage_role"] = resolve_personnage_role(o)
if o["kind"] in ("dialogue_box", "quiz_box", "score_widget"):
# Widgets d'interface (voir "🖥️ Interface",
# screens/rendering/dialogue_box_style.py) : ni
# collision, ni cible/source de règle — juste leur
# style, lu par static/js/play/dialogue-box-controller.js.
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
continue
# Boîte de collision (voir static/js/play/conditions.js::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# seulement "personnage" — un décor/fond peut aussi servir
# d'obstacle.
o["collision"] = resolve_collision_settings(o)
# Règles "à la collision/dans un périmètre -> action" (voir
# "🧩 Collision", templates/scene_edit.html, et
# static/js/play/collision-rules-controller.js pour leur
# exécution) — jamais sur "fond" (jamais une cible), ni sur
# le personnage "joueur" lui-même (ce sont TOUJOURS les
# AUTRES objets qui réagissent à SA présence, jamais
# l'inverse).
if o["kind"] != "fond" and not (o["kind"] == "personnage" and o.get("personnage_role") == "joueur"):
o["collision_rules"] = resolve_collision_rules(o)
payload_screens.append({**s, "elements": objects})
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
# écrans, écrans-modèles compris (include_templates=True) — pas
# seulement les "vrais" écrans ci-dessus. Un élément de jeu réutilisable
# (ex. "mail content") peut avoir son propre déclencheur/sa propre
# animation posée dans l'éditeur de SON écran-modèle (voir
# screen_edit.html) : sans ses entrées ici, gameData.flows/
# gameData.animations n'auraient jamais contenu cet écran-modèle côté
# client, et findTriggerNode()/collectAnimationClips() (templates/
# play.html) — pourtant déjà écrits pour les chercher — n'auraient
# jamais rien trouvé. On ne les ajoute PAS à payload_screens : un
# écran-modèle ne doit jamais être rendu comme un vrai <div
# class="playScreen"> (il n'est jamais affiché tel quel, seulement
# rechargé en direct à l'intérieur d'un élément qui l'utilise).
# Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
# écrans. Animations d'un personnage (Phase 8) — résolues ici, à CHAQUE
# affichage de la page de jeu (donc toujours à partir du personnage
# Forge ACTUELLEMENT assigné), plutôt que figées dans le nœud de flow/
# le clip de Timeline au moment où le créateur les a configurés : sans
@@ -99,24 +88,17 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
# voir runActionNode()/applyAnimationClip() qui font cette résolution
# à l'exécution à partir de gameData.personnage_animations plutôt que
# depuis un data_value/custom_keyframes qui contiendrait des frames
# déjà résolues). include_templates=True : un personnage peut vivre
# dans un écran-modèle d'élément de jeu réutilisable, comme les flows/
# animations ci-dessous.
# déjà résolues).
personnage_animations = {}
for s in list_screens(slug, include_templates=True):
for s in screens_:
flows[str(s["id"])] = {
"nodes": list_flow_nodes(slug, s["id"]),
"edges": list_flow_edges(slug, s["id"]),
}
animations[str(s["id"])] = list_animation_clips(slug, s["id"])
if s["kind"] == "jeu_2d":
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage":
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
else:
for el in list_elements(slug, s["id"]):
if el.get("widget") == "personnage":
personnage_animations[str(el["id"])] = resolve_personnage_animations(el)
for o in list_scene_objects(slug, s["id"]):
if o["kind"] == "personnage":
personnage_animations[str(o["id"])] = resolve_personnage_animations(o)
definitions = db.list_definitions(slug)
data = {}
@@ -156,30 +138,6 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
for f in full["fields"]
]
# element_type_id -> id de son écran-modèle : nécessaire côté client (voir
# collectAnimationClips() dans play.html) pour savoir, quand un élément de
# jeu réutilisable (ex. "mail content") est posé sur un écran, quels
# autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent
# aussi être joués à l'affichage de cet écran — sans quoi une animation
# posée directement dans l'éditeur du modèle ne se jouait jamais quand le
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
# Arbre complet (éléments + descendants) de l'écran-modèle de chaque
# élément de jeu réutilisable — voir screens/element_types/
# load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu
# créateur ET jeu publié en ligne). Nécessaire ici pour que le port
# client-side de l'export Web/SCORM (static/js/play/offline/,
# rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de
# Répéteur) puisse le rejouer sans base de données — inoffensif pour
# le runtime en ligne, qui l'ignore.
element_type_templates = {}
for type_id_str, template_screen_id in element_types.items():
if template_screen_id:
tree = _load_template_tree(slug, int(type_id_str))
if tree:
element_type_templates[type_id_str] = tree
# Instantané des variables globales, pour qu'un nœud Condition puisse en
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
@@ -198,9 +156,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
# ci-dessus sont déjà indexés par id, jamais par nom.
definition_names = {str(d["id"]): d["name"] for d in definitions}
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici
# pour que static/js/play/dialogue-box-controller.js puisse retrouver,
# à l'exécution d'une action "quete" (collision-rules-controller.js),
# le dialogue de la colonne correspondant au STATUT ACTUEL de la
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur.
quests = db.list_quests(slug)
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations,
"definition_names": definition_names, "element_type_templates": element_type_templates,
"definition_names": definition_names,
"quests": quests,
}
-14
View File
@@ -1,14 +0,0 @@
def _apply_ctx(text, ctx):
"""À l'intérieur d'un Répéteur de données, {{champ}} est remplacé par
la valeur de ce champ pour la ligne en cours — appliqué au contenu ET
aux attributs de l'élément et de tous ses descendants."""
if not ctx or not text:
return text
for k, v in ctx.items():
# "_forge_play_mode" (voir render_element_html.py) est un indicateur
# interne posé dans ce même ctx, pas une valeur de champ {{...}} —
# à ignorer ici.
if k == "_forge_play_mode":
continue
text = text.replace("{{" + k + "}}", v)
return text
-10
View File
@@ -1,10 +0,0 @@
import html as html_lib
def _attr_string(attrs, extra_style):
parts = [f'style="{html_lib.escape(extra_style)}"']
for k, v in attrs.items():
if v in (None, "", False):
continue
parts.append(html_lib.escape(str(k)) if v is True else f'{html_lib.escape(str(k))}="{html_lib.escape(str(v))}"')
return " ".join(parts)

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