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>
This commit is contained in:
william
2026-09-03 11:20:58 +02:00
co-authored by Claude Sonnet 5
parent 390ee831ca
commit 2347a70c45
27 changed files with 774 additions and 41 deletions
+140
View File
@@ -0,0 +1,140 @@
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en
cours d'une quête déclenchée par une règle de collision "quete" (voir
static/js/play/dialogue-box-controller.js) — header/body/footer
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
l'éditeur de dialogue de quête (screens/rendering/personnage_names.py)."""
import db
import screens
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_dialogue_box"):
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def test_add_dialogue_box_has_sensible_default_size(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
assert obj["kind"] == "dialogue_box"
assert obj["width"] == 320
assert obj["height"] == 150
def test_dialogue_box_default_style(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
style = screens.resolve_dialogue_box_style(obj)
assert style == screens.DEFAULT_DIALOGUE_BOX_STYLE
def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert "dialogueBoxHeader" in html
assert "dialogueBoxBody" in html
assert "dialogueBoxFooter" in html
assert f'data-object-id="{obj_id}"' in html
def test_set_scene_object_dialogue_box_style_persists_and_sanitizes(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
style = screens.set_scene_object_dialogue_box_style(slug, obj_id, {
"font_family": "Georgia", "font_size": 999, "font_weight": "bold",
"text_color": "#ff0000", "header_bg": "#000000", "unknown_key": "ignored",
})
assert style["font_family"] == "Georgia"
assert style["font_size"] == 48 # bornée au maximum autorisé
assert style["font_weight"] == "bold"
assert style["text_color"] == "#ff0000"
assert style["header_bg"] == "#000000"
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_dialogue_box_style(obj) == style
def test_dialogue_box_route_persists_style(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/dialogue-box-style",
data='{"style": {"header_bg": "#123456"}}', content_type="application/json",
)
assert resp.get_json()["style"]["header_bg"] == "#123456"
def test_dialogue_box_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/dialogue-box-style", data='{"style": {}}', content_type="application/json")
assert resp.status_code == 404
def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="dialogue_box")
payload = screens.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
box = next(o for o in scene["elements"] if o["kind"] == "dialogue_box")
assert "collision_rules" not in box
assert "collision" not in box
assert box["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
def test_full_game_payload_exposes_quests_game_wide(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
db.create_quest(slug, "Trouver la clé")
payload = screens.full_game_payload(slug)
assert [q["title"] for q in payload["quests"]] == ["Trouver la clé"]
# ---------- Nom d'un personnage ("ℹ️ Informations") ----------
def test_scene_object_name_is_none_by_default(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
obj = screens.get_scene_object(slug, obj_id)
assert obj["name"] is None
def test_set_scene_object_name_persists_and_trims(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_name(slug, obj_id, " Le Sorcier ")
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
def test_set_scene_object_name_blank_clears_it(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_name(slug, obj_id, "Le Sorcier")
screens.set_scene_object_name(slug, obj_id, " ")
assert screens.get_scene_object(slug, obj_id)["name"] is None
def test_scene_object_name_route_persists(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
assert resp.status_code == 200
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_personnage_names"))
scene_a = screens.create_screen(slug, "Scène A", kind="jeu_2d")
scene_b = screens.create_screen(slug, "Scène B", kind="jeu_2d")
zed_id = screens.add_scene_object(slug, scene_a, kind="personnage")
screens.set_scene_object_name(slug, zed_id, "Zed le forgeron")
anna_id = screens.add_scene_object(slug, scene_b, kind="personnage")
screens.set_scene_object_name(slug, anna_id, "Anna")
unnamed_id = screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré
decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # pas un personnage, ignoré
screens.set_scene_object_name(slug, decor_id, "Un décor nommé")
assert screens.list_personnage_names(slug) == ["Anna", "Zed le forgeron"]
+8 -4
View File
@@ -74,15 +74,19 @@ def test_delete_quest_removes_it(tmp_game_slug_cleanup):
def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status():
"""speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations",
screens/rendering/personnage_names.py) — "Le Sorcier" est donc un nom
valide comme n'importe quel autre, seul un speaker/texte VIDE ou un
élément mal formé est rejeté."""
raw = {
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}, {"speaker": "joueur", "text": " "}],
"en_cours": [{"speaker": "sorcier", "text": "invalide"}, "pas un objet"],
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}],
"en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"],
"terminee": "pas une liste",
"statut_inconnu": [{"speaker": "pnj", "text": "ignoré"}],
"statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}],
}
cleaned = db.sanitize_quest_dialogues(raw)
assert cleaned == {
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}],
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}],
"en_cours": [],
"terminee": [],
}