Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""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'un dialogue déclenché par une règle de collision "dialogue" (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 d'un déclencheur (screens/rendering/scene_object_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
|
||
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
|
||
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
|
||
# (forgeResetDialogueBoxFooter, static/js/play/dialogue-box-controller.js)
|
||
# le cherche par ce sélecteur pour y (re)poser le bouton "Suivant".
|
||
assert 'data-dialogue-role="header"' in html
|
||
assert 'data-dialogue-role="body"' in html
|
||
assert 'data-dialogue-role="footer"' 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_completable_dialogue_ids_game_wide(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")
|
||
rules = [{
|
||
"trigger": "collision",
|
||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True},
|
||
}]
|
||
screens.set_scene_object_collision_rules(slug, obj_id, screens.sanitize_collision_rules(rules))
|
||
payload = screens.full_game_payload(slug)
|
||
assert payload["completable_dialogue_ids"] == ["d_1"]
|
||
|
||
|
||
# ---------- 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_named_scene_object_names_is_alphabetical_deduped_and_includes_any_kind(tmp_game_slug_cleanup):
|
||
""""il peut sélectionner le nom du personnage qui parle" -> étendu à
|
||
N'IMPORTE QUEL objet de scène nommé (pas seulement un personnage),
|
||
voir screens/rendering/scene_object_names.py."""
|
||
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_object_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") # nommé -> INCLUS (pas réservé au personnage)
|
||
screens.set_scene_object_name(slug, decor_id, "Un coffre")
|
||
dialogue_box_id = screens.add_scene_object(slug, scene_a, kind="dialogue_box") # widget d'UI, jamais un "qui parle"
|
||
screens.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur")
|
||
|
||
assert screens.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"]
|
||
|
||
|
||
# ---------- Widgets "❓ Boîte à quiz" / "🏆 Score" ----------
|
||
|
||
def test_add_quiz_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="quiz_box")
|
||
obj = screens.get_scene_object(slug, obj_id)
|
||
assert obj["kind"] == "quiz_box"
|
||
assert obj["width"] == 340
|
||
assert obj["height"] == 240
|
||
|
||
|
||
def test_add_score_widget_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="score_widget")
|
||
obj = screens.get_scene_object(slug, obj_id)
|
||
assert obj["kind"] == "score_widget"
|
||
assert obj["width"] == 140
|
||
assert obj["height"] == 50
|
||
|
||
|
||
def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup):
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||
obj = screens.get_scene_object(slug, obj_id)
|
||
html = screens.render_scene_object(obj)
|
||
assert 'data-quiz-role="header"' in html # nom de l'objet déclencheur, rempli en jeu
|
||
assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps
|
||
assert 'data-quiz-role="choices"' in html
|
||
assert f'data-object-id="{obj_id}"' in html
|
||
assert "quizBoxFooter" not in html # pas de pied, une question se résout au clic
|
||
|
||
|
||
def test_render_score_widget_shows_zero_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="score_widget")
|
||
obj = screens.get_scene_object(slug, obj_id)
|
||
html = screens.render_scene_object(obj)
|
||
assert 'data-score-role="value"' in html
|
||
assert f'data-object-id="{obj_id}"' in html
|
||
|
||
|
||
def test_quiz_box_and_score_widget_share_the_same_style_system_as_dialogue_box(tmp_game_slug_cleanup):
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
quiz_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||
style = screens.set_scene_object_dialogue_box_style(slug, quiz_id, {"header_bg": "#abcdef"})
|
||
assert style["header_bg"] == "#abcdef"
|
||
obj = screens.get_scene_object(slug, quiz_id)
|
||
assert screens.resolve_dialogue_box_style(obj)["header_bg"] == "#abcdef"
|
||
|
||
|
||
def test_quiz_box_and_score_widget_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="quiz_box")
|
||
screens.add_scene_object(slug, screen_id, kind="score_widget")
|
||
payload = screens.full_game_payload(slug)
|
||
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||
quiz = next(o for o in scene["elements"] if o["kind"] == "quiz_box")
|
||
score = next(o for o in scene["elements"] if o["kind"] == "score_widget")
|
||
for widget in (quiz, score):
|
||
assert "collision_rules" not in widget
|
||
assert "collision" not in widget
|
||
assert widget["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
|