Files
Forge-Engine/scripts/game/build_demo_dialogues.py
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

243 lines
12 KiB
Python

"""Crée un jeu de démo "Démo — Dialogues & Surbrillance" qui exerce, bout
en bout, les trois mécanismes de guidage du joueur déjà présents dans le
moteur (voir README, section "Survol, séquences temporisées, surbrillance,
overlay, verrouillage") :
- Écran 1 "Tutoriel" : une boîte de dialogue (widget "superposition")
s'ouvre TOUTE SEULE à l'affichage de l'écran (déclencheur "affichage"),
accueille le joueur et lui indique quoi faire. Son bouton "OK" la
referme ET met un autre bouton en surbrillance (liseré clignotant) pour
guider le clic suivant. Cliquer ce bouton met fin à la surbrillance, le
désactive (grisé, ne peut plus être recliqué) et ouvre une DEUXIÈME
boîte de dialogue "Bravo" en réaction au clic — fermable à son tour.
- Écran 2 "Message différé" : une boîte de dialogue "arrive toute seule"
3 secondes après l'affichage de l'écran (déclencheur "affichage" ->
action "attendre" -> action "Modifier un élément → Visibilité"), sans
aucune action du joueur — utile pour un message de mentor/notification.
Ce script utilise le VRAI client de test Flask contre les VRAIES routes de
l'appli (mêmes routes que celles appelées par le navigateur), et crée un
jeu PERSISTANT sous projects/ (contrairement aux tests pytest, il n'est
PAS supprimé à la fin) — pensé pour être ouvert et joué dans le navigateur
après coup.
Couleur du texte posée explicitement (#f5f6fa) sur chaque titre/texte à
l'intérieur d'une boîte de dialogue : le moteur ne fige JAMAIS de couleur
par défaut à la création d'un élément (voir
default_style_for_widget.py — "la figer dès la création ferait apparaître
une couleur que personne n'a demandée"), un titre/texte fraîchement posé
retombe donc sur le texte sombre par défaut de Bulma, pensé pour un fond
clair — illisible sur le fond sombre d'une boîte de dialogue tant que
personne n'a choisi une couleur claire à la main dans le panneau de
propriétés. Sans ce réglage explicite ici, la démo donnait l'impression
trompeuse que "la boîte de dialogue est assombrie" (elle ne l'est pas :
seul son texte, invisible par défaut sur fond sombre, l'était). Valeur
volontairement DIFFÉRENTE de la couleur suggérée par défaut dans le
panneau (#e8eaf0) : save_element_controls.py ignore délibérément un
réglage renvoyé identique à sa valeur par défaut tant qu'il n'a jamais été
personnalisé (même logique anti-figeage), donc envoyer #e8eaf0 telle
quelle ici n'aurait eu AUCUN effet, silencieusement.
Usage : python3 scripts/game/build_demo_dialogues.py
"""
import json
import os
import re
import sys
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from flask.testing import FlaskClient
from werkzeug.test import TestResponse
import db
import routes # noqa: F401 - enregistre toutes les routes
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
from core.flask_app import app
def _redirect_id(resp: TestResponse, pattern: str) -> int:
match = re.search(pattern, resp.headers["Location"]) # NOSONAR S8371 - Location attendue, KeyError = echec clair
match = db.assert_not_none(
match,
f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}", # NOSONAR S8371 - idem
)
return int(match.group(1))
def _create_screen(client: FlaskClient, slug: str, name: str) -> int:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return _redirect_id(resp, r"/screens/(\d+)/edit")
def _add_element(client: FlaskClient, slug: str, screen_id: int, widget: str) -> int:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False
)
return _redirect_id(resp, r"selected=(\d+)")
def _add_child(client: FlaskClient, slug: str, parent_id: int, widget: str) -> int:
resp = client.post(
f"/game/{slug}/elements/{parent_id}/children/add", data={"widget": widget}, follow_redirects=False
)
return _redirect_id(resp, r"selected=(\d+)")
def _save(client: FlaskClient, slug: str, element_id: int, **ctrl: Any) -> None:
data = {f"ctrl_{k}": v for k, v in ctrl.items()}
client.post(f"/game/{slug}/elements/{element_id}/save", data=data)
def _add_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> int:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({**fields}),
content_type="application/json",
)
assert resp.status_code == 200, ( # nosec B101 # noqa: S101 - script d'usage unique
resp.get_data(as_text=True)
)
result: int = resp.get_json()["id"]
return result
def _add_edge(client: FlaskClient, slug: str, screen_id: int, from_id: int, to_id: int, from_port: str = "out") -> None:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/edges/add",
data=json.dumps({"from_node_id": from_id, "from_port": from_port, "to_node_id": to_id}),
content_type="application/json",
)
assert resp.status_code == 200, ( # nosec B101 # noqa: S101 - script d'usage unique
resp.get_data(as_text=True)
)
def _modifier_element(client: FlaskClient, slug: str, screen_id: int, target_id: int, prop: str, value: str) -> int:
return _add_node(
client,
slug,
screen_id,
node_type="action",
action_type="modifier_element",
target_element_id=target_id,
element_property=prop,
element_value=value,
)
def build() -> None:
app.config["TESTING"] = True
with app.test_client() as client:
resp = client.post("/games/new", data={"name": "Demo Dialogues Surbrillance"}, follow_redirects=False)
assert resp.status_code == 302, ( # nosec B101 # noqa: S101 - script d'usage unique
resp.get_data(as_text=True)
)
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
print(f"Jeu créé : slug={slug}")
# ---------------- Écran 1 : Tutoriel ----------------
screen1 = _create_screen(client, slug, "Tutoriel")
overlay_bienvenue = _add_element(client, slug, screen1, "superposition")
_save(client, slug, overlay_bienvenue, couleur_boite="#20263a", arrondi="16")
t1 = _add_child(client, slug, overlay_bienvenue, "titre")
_save(client, slug, t1, content="Bienvenue !", color="#f5f6fa")
t2 = _add_child(client, slug, overlay_bienvenue, "texte")
_save(
client,
slug,
t2,
content=(
"Ce petit tutoriel te montre comment fonctionnent les dialogues et la "
"surbrillance. Clique sur OK pour commencer."
),
color="#f5f6fa",
)
btn_ok = _add_child(client, slug, overlay_bienvenue, "bouton")
_save(client, slug, btn_ok, content="OK, c'est parti")
btn_target = _add_element(client, slug, screen1, "bouton")
_save(client, slug, btn_target, content="Clique-moi !")
overlay_bravo = _add_element(client, slug, screen1, "superposition")
_save(client, slug, overlay_bravo, couleur_boite="#1f3a24", arrondi="16")
t3 = _add_child(client, slug, overlay_bravo, "titre")
_save(client, slug, t3, content="Bravo !", color="#f5f6fa")
t4 = _add_child(client, slug, overlay_bravo, "texte")
_save(client, slug, t4, content="Tu as cliqué le bon bouton. Le tutoriel est terminé.", color="#f5f6fa")
btn_close_bravo = _add_child(client, slug, overlay_bravo, "bouton")
_save(client, slug, btn_close_bravo, content="Fermer")
# Déclencheur "affichage" (écran entier) -> ouvre le dialogue de bienvenue.
trig_affichage = _add_node(client, slug, screen1, node_type="trigger", trigger_event="affichage")
act_open_bienvenue = _modifier_element(client, slug, screen1, overlay_bienvenue, "visibilite", "visible")
_add_edge(client, slug, screen1, trig_affichage, act_open_bienvenue)
# Clic sur "OK" -> ferme le dialogue de bienvenue ET met le bouton cible en surbrillance.
trig_ok = _add_node(client, slug, screen1, node_type="trigger", trigger_element_id=btn_ok, trigger_event="clic")
act_close_bienvenue = _modifier_element(client, slug, screen1, overlay_bienvenue, "visibilite", "masque")
act_highlight_on = _modifier_element(client, slug, screen1, btn_target, "surbrillance", "on")
_add_edge(client, slug, screen1, trig_ok, act_close_bienvenue)
_add_edge(client, slug, screen1, trig_ok, act_highlight_on)
# Clic sur le bouton cible -> arrête la surbrillance, le désactive, ouvre "Bravo".
trig_target = _add_node(
client, slug, screen1, node_type="trigger", trigger_element_id=btn_target, trigger_event="clic"
)
act_highlight_off = _modifier_element(client, slug, screen1, btn_target, "surbrillance", "off")
act_lock_target = _modifier_element(client, slug, screen1, btn_target, "desactive", "on")
act_open_bravo = _modifier_element(client, slug, screen1, overlay_bravo, "visibilite", "visible")
_add_edge(client, slug, screen1, trig_target, act_highlight_off)
_add_edge(client, slug, screen1, trig_target, act_lock_target)
_add_edge(client, slug, screen1, trig_target, act_open_bravo)
# Clic sur "Fermer" (dans "Bravo") -> referme "Bravo".
trig_close_bravo = _add_node(
client, slug, screen1, node_type="trigger", trigger_element_id=btn_close_bravo, trigger_event="clic"
)
act_close_bravo = _modifier_element(client, slug, screen1, overlay_bravo, "visibilite", "masque")
_add_edge(client, slug, screen1, trig_close_bravo, act_close_bravo)
# ---------------- Écran 2 : Message différé ----------------
screen2 = _create_screen(client, slug, "Message différé")
overlay_mentor = _add_element(client, slug, screen2, "superposition")
_save(client, slug, overlay_mentor, couleur_boite="#2a2440", arrondi="16")
t5 = _add_child(client, slug, overlay_mentor, "titre")
_save(client, slug, t5, content="Message du mentor", color="#f5f6fa")
t6 = _add_child(client, slug, overlay_mentor, "texte")
_save(
client,
slug,
t6,
content="Ce message est apparu tout seul, 3 secondes après l'arrivée sur cet écran — sans aucun clic.",
color="#f5f6fa",
)
btn_close_mentor = _add_child(client, slug, overlay_mentor, "bouton")
_save(client, slug, btn_close_mentor, content="Fermer")
info2 = _add_element(client, slug, screen2, "texte")
_save(client, slug, info2, content="Attends 3 secondes sans rien faire : un message va apparaître tout seul.")
trig_affichage2 = _add_node(client, slug, screen2, node_type="trigger", trigger_event="affichage")
act_wait = _add_node(client, slug, screen2, node_type="action", action_type="attendre", data_value="3")
act_open_mentor = _modifier_element(client, slug, screen2, overlay_mentor, "visibilite", "visible")
_add_edge(client, slug, screen2, trig_affichage2, act_wait)
_add_edge(client, slug, screen2, act_wait, act_open_mentor)
trig_close_mentor = _add_node(
client, slug, screen2, node_type="trigger", trigger_element_id=btn_close_mentor, trigger_event="clic"
)
act_close_mentor = _modifier_element(client, slug, screen2, overlay_mentor, "visibilite", "masque")
_add_edge(client, slug, screen2, trig_close_mentor, act_close_mentor)
print(f"Terminé. Ouvre http://127.0.0.1:5050/game/{slug}/play pour tester.")
print(f"Éditeur : http://127.0.0.1:5050/game/{slug}/screens/{screen1}/edit")
if __name__ == "__main__":
build()