diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index 9f1aa16b..aa3a9708 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -7,7 +7,7 @@ _MAX_SPEAKER_LENGTH = 60 def sanitize_quest_dialogues(raw_dialogues): """Une réplique = {speaker: '', text: '...'} — le nom vient du champ "ℹ️ Informations" d'un objet de scène kind= - "personnage" (voir screens/rendering/personnage_names.py), ou + "personnage" (voir screens/rendering/scene_object_names.py), ou "Joueur" (toujours proposé, voir static/js/quests/quest-editor.js) : texte libre côté serveur (jamais un enum fixe — n'importe quel nom donné à un personnage doit être accepté), juste borné en longueur. diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index c05f004c..f0a53f9d 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -141,7 +141,8 @@ def render_scene_edit(slug, screen_id, game): # (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 aux - # personnages, toutes scènes du jeu confondues. - personnage_names_json=json.dumps(screens.list_personnage_names(slug)), + # 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)), ) diff --git a/screens/__init__.py b/screens/__init__.py index e98a027e..246e62a0 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -88,7 +88,7 @@ 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.personnage_names import list_personnage_names +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 diff --git a/screens/rendering/personnage_names.py b/screens/rendering/personnage_names.py deleted file mode 100644 index ed88fb01..00000000 --- a/screens/rendering/personnage_names.py +++ /dev/null @@ -1,19 +0,0 @@ -from ..screens_repo.list_screens import list_screens -from ..scenes.list_scene_objects import list_scene_objects - - -def list_personnage_names(slug): - """Noms distincts de tous les personnages nommés (voir "ℹ️ - Informations", templates/scene_edit.html), toutes scènes 2D du jeu - confondues — une quête (voir db/quests/) est GAME-WIDE, son dialogue - peut donc mettre en scène un PNJ posé sur une autre scène que celle - où la règle de collision se déclenche. Ordre alphabétique, jamais de - doublon.""" - names = set() - for s in list_screens(slug): - if s["kind"] != "jeu_2d": - continue - for o in list_scene_objects(slug, s["id"]): - if o["kind"] == "personnage" and o.get("name"): - names.add(o["name"]) - return sorted(names) diff --git a/screens/rendering/scene_object_names.py b/screens/rendering/scene_object_names.py new file mode 100644 index 00000000..97f06551 --- /dev/null +++ b/screens/rendering/scene_object_names.py @@ -0,0 +1,25 @@ +from ..screens_repo.list_screens import list_screens +from ..scenes.list_scene_objects import list_scene_objects + +# Le widget "💬 Boîte de dialogue" (kind="dialogue_box") n'est pas un +# acteur de la scène — il n'a pas de sens comme "qui parle" dans un +# dialogue de quête, contrairement à N'IMPORTE QUEL AUTRE objet +# (personnage, décor, fond). +_EXCLUDED_KINDS = {"dialogue_box"} + + +def list_named_scene_object_names(slug): + """Noms distincts de tous les objets de scène nommés (voir "ℹ️ + Informations", templates/scene_edit.html) — n'importe quel kind + (personnage, décor, fond), toutes scènes 2D du jeu confondues : une + quête (voir db/quests/) est GAME-WIDE, son dialogue peut donc mettre + en scène un objet posé sur une autre scène que celle où la règle de + collision se déclenche. Ordre alphabétique, jamais de doublon.""" + names = set() + for s in list_screens(slug): + if s["kind"] != "jeu_2d": + continue + for o in list_scene_objects(slug, s["id"]): + if o["kind"] not in _EXCLUDED_KINDS and o.get("name"): + names.add(o["name"]) + return sorted(names) diff --git a/screens/scenes/set_scene_object_name.py b/screens/scenes/set_scene_object_name.py index 9878c849..45acf2c6 100644 --- a/screens/scenes/set_scene_object_name.py +++ b/screens/scenes/set_scene_object_name.py @@ -9,7 +9,7 @@ def set_scene_object_name(slug, object_id, name): partout ailleurs, mais jamais éditable avant le panneau "ℹ️ Informations", templates/scene_edit.html). Pour un personnage, ce nom est aussi celui proposé comme "qui parle" dans l'éditeur de dialogue - de quête (voir screens/rendering/personnage_names.py, static/js/ + de quête (voir screens/rendering/scene_object_names.py, static/js/ quests/quest-editor.js).""" ensure_scene_schema(slug) conn = db.connect(slug) diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index 95d3d39e..8c28c345 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -27,14 +27,15 @@ var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; // besoin (collisionRuleActionSummary). var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; -// Noms de personnages nommés (voir "ℹ️ Informations", templates/ -// scene_edit.html) — proposés comme "qui parle" pour une réplique, en -// plus de "Joueur" (toujours disponible, jamais un objet de scène). -var PERSONNAGE_NAMES = (typeof PERSONNAGE_NAMES_JSON !== 'undefined') ? PERSONNAGE_NAMES_JSON : []; +// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/ +// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas +// seulement un personnage — proposés comme "qui parle" pour une réplique, +// en plus de "Joueur" (toujours disponible, jamais un objet de scène). +var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : []; // Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/ // .questBubble-pnj, static/style.css) même si le nom affiché est -// n'importe quel personnage nommé — "Joueur" à droite en vert, n'importe +// n'importe quel objet nommé — "Joueur" en vert, n'importe // quel autre nom à gauche en bleu (voir demande utilisateur : "les bulles // du pnj et du joueur sont d'une couleur différente"). function questBubbleKind(speaker) { @@ -198,24 +199,33 @@ function renderQuestFieldsModalBody(questId) { // renommé/supprimé depuis) pour ne jamais perdre silencieusement la // valeur déjà enregistrée. function questSpeakerOptions(currentSpeaker) { - var options = ['Joueur'].concat(PERSONNAGE_NAMES); + var options = ['Joueur'].concat(SCENE_OBJECT_NAMES); if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker); return options; } +// Une bulle = header (qui parle, menu déroulant des objets de scène +// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones +// distinctes demandées explicitement, pas un seul bloc. Toujours centrée +// dans sa colonne, 70% de la largeur (voir .questBubble, static/style.css) — +// aucune limite au nombre de bulles par colonne (voir questAddBubble). function questDialogueBubbleHtml(line, status, index) { var kind = questBubbleKind(line.speaker); var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) { return ''; }).join(''); return ( - '
' + + '
' + '
' + - '' + - '' + - '' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + '
' + '
' ); @@ -324,7 +334,7 @@ function questSaveDialogues(quest) { // — juste un défaut de départ, l'auteur choisit ensuite librement dans // le menu déroulant de la réplique. function questNextDefaultSpeaker(lastSpeaker) { - if (lastSpeaker === 'Joueur') return PERSONNAGE_NAMES[0] || 'PNJ'; + if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ'; return 'Joueur'; } diff --git a/static/style.css b/static/style.css index e5a75d14..b96649fa 100644 --- a/static/style.css +++ b/static/style.css @@ -817,31 +817,43 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d display:flex; flex-direction:column; gap:8px; min-height:160px; } .questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; } -.questDialogueColumnBubbles{ display:flex; flex-direction:column; gap:8px; flex:1; } -.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } +/* Bulles alignées VERTICALEMENT, centrées dans la colonne, reliées par un + trait entre chacune (voir ::before sur .questBubbleRow ci-dessous) — + demandé explicitement plutôt qu'un alignement gauche/droite. */ +.questDialogueColumnBubbles{ display:flex; flex-direction:column; align-items:center; flex:1; padding-top:4px; } +.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; margin-top:4px; } -.questBubbleRow{ display:flex; } -.questBubbleRow-pnj{ justify-content:flex-start; } -.questBubbleRow-joueur{ justify-content:flex-end; } -.questBubble{ - display:flex; flex-direction:column; gap:4px; max-width:88%; border-radius:14px; padding:8px 10px; - animation:collisionLinkIn .25s ease backwards; +.questBubbleRow{ display:flex; justify-content:center; width:100%; position:relative; } +.questBubbleRow:not(:first-child)::before{ + content:''; position:absolute; top:-14px; left:50%; transform:translateX(-50%); + width:2px; height:14px; background:var(--border); } -.questBubble-pnj{ background:linear-gradient(135deg, #2f6fed, #1e4fb0); border-bottom-left-radius:4px; } -.questBubble-joueur{ background:linear-gradient(135deg, #2ecc71, #1e9e57); border-bottom-right-radius:4px; } +/* Occupe 70% de la largeur de la colonne, header + body distincts (voir + demande utilisateur) plutôt qu'un seul bloc — header : qui parle + (sélectionné parmi les objets de scène nommés) ; body : le texte. */ +.questBubble{ + width:70%; display:flex; flex-direction:column; border-radius:12px; overflow:hidden; + margin-bottom:14px; box-shadow:0 4px 14px rgba(0,0,0,.3); animation:collisionLinkIn .25s ease backwards; +} +.questBubbleHeader{ display:flex; align-items:center; justify-content:space-between; gap:6px; padding:6px 10px; } +.questBubbleBody{ padding:10px; } +.questBubble-pnj .questBubbleHeader{ background:#1e4fb0; } +.questBubble-pnj .questBubbleBody{ background:rgba(47,111,237,.16); } +.questBubble-joueur .questBubbleHeader{ background:#1e9e57; } +.questBubble-joueur .questBubbleBody{ background:rgba(46,204,113,.16); } .questBubbleSpeaker{ - font-size:10.5px; font-weight:700; color:rgba(255,255,255,.85); cursor:pointer; - background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; + font-size:11px; font-weight:700; color:#fff; cursor:pointer; + background:transparent; border:none; padding:0; font-family:inherit; max-width:100%; flex:1; } .questBubbleSpeaker option{ color:#111; } .questBubbleText{ resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px; - font-family:inherit; padding:0; min-height:1.4em; + font-family:inherit; padding:0; min-height:1.4em; width:100%; } .questBubbleText::placeholder{ color:rgba(255,255,255,.6); } .questBubbleText:focus{ outline:none; } .questBubbleDelete{ - align-self:flex-end; background:none; border:none; color:rgba(255,255,255,.6); cursor:pointer; + flex-shrink:0; background:none; border:none; color:rgba(255,255,255,.75); cursor:pointer; font-size:11px; padding:0; transition:color .15s ease; } .questBubbleDelete:hover{ color:#fff; } diff --git a/templates/scene_edit.html b/templates/scene_edit.html index d97be914..871ebcbe 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -268,16 +268,19 @@
- {% if obj.kind == 'personnage' %} - + {% if obj.kind != 'dialogue_box' %} +
ℹ️ Informations
- +
@@ -556,8 +559,9 @@ // "+ Action" ouvre directement cet éditeur (renderQuestPickerStep). var QUESTS_JSON = {{ quests_json|safe }}; // "qui parle" dans l'éditeur de dialogue (voir "ℹ️ Informations" + - // static/js/quests/quest-editor.js::questSpeakerOptions). - var PERSONNAGE_NAMES_JSON = {{ personnage_names_json|safe }}; + // static/js/quests/quest-editor.js::questSpeakerOptions) — n'importe + // quel objet de scène nommé, pas seulement un personnage. + var SCENE_OBJECT_NAMES_JSON = {{ scene_object_names_json|safe }}; diff --git a/tests/test_dialogue_box.py b/tests/test_dialogue_box.py index f4ba2333..056e5049 100644 --- a/tests/test_dialogue_box.py +++ b/tests/test_dialogue_box.py @@ -5,7 +5,7 @@ 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).""" +l'éditeur de dialogue de quête (screens/rendering/scene_object_names.py).""" import db import screens @@ -125,8 +125,11 @@ def test_scene_object_name_route_persists(client, tmp_game_slug_cleanup): 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")) +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") @@ -134,7 +137,9 @@ def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_gam 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é") + 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_personnage_names(slug) == ["Anna", "Zed le forgeron"] + assert screens.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"] diff --git a/tests/test_quests.py b/tests/test_quests.py index 60d120c2..a5045ad3 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -75,7 +75,7 @@ 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 + screens/rendering/scene_object_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 = {