Dev #11
@@ -7,7 +7,7 @@ _MAX_SPEAKER_LENGTH = 60
|
||||
def sanitize_quest_dialogues(raw_dialogues):
|
||||
"""Une réplique = {speaker: '<nom du personnage>', 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.
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 '<option value="' + name.replace(/"/g, '"') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>';
|
||||
}).join('');
|
||||
return (
|
||||
'<div class="questBubbleRow questBubbleRow-' + kind + '">' +
|
||||
'<div class="questBubbleRow">' +
|
||||
'<div class="questBubble questBubble-' + kind + '">' +
|
||||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'<div class="questBubbleHeader">' +
|
||||
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="questBubbleBody">' +
|
||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
+26
-14
@@ -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; }
|
||||
|
||||
@@ -268,16 +268,19 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if obj.kind == 'personnage' %}
|
||||
<!-- "ℹ️ Informations" : le nom donné au personnage (voir
|
||||
screens/scenes/set_scene_object_name.py) — c'est CE nom qui est
|
||||
ensuite proposé comme "qui parle" dans l'éditeur de dialogue de
|
||||
quête (voir "🗺️ Quêtes", static/js/quests/quest-editor.js). -->
|
||||
{% if obj.kind != 'dialogue_box' %}
|
||||
<!-- "ℹ️ Informations" : le nom donné à L'OBJET (voir
|
||||
screens/scenes/set_scene_object_name.py) — n'importe quel kind
|
||||
(personnage, décor, fond), pas seulement un personnage — c'est
|
||||
CE nom qui est ensuite proposé comme "qui parle" dans l'éditeur
|
||||
de dialogue de quête (voir "🗺️ Quêtes", static/js/quests/
|
||||
quest-editor.js) : un dialogue peut mettre en scène n'importe
|
||||
quel objet, pas seulement un personnage. -->
|
||||
<details class="propGroup" open>
|
||||
<summary>ℹ️ Informations</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="field">
|
||||
<label class="label is-small">Nom du personnage</label>
|
||||
<label class="label is-small">Nom de l'objet</label>
|
||||
<div class="control"><input class="input is-small" type="text" id="scene-obj-name" value="{{ obj.name or '' }}" placeholder="ex. Le Sorcier" onchange="saveSceneObjectName({{ obj.id }}, this.value)"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user