Quêtes/collision : personnages nommés, dialogues qui parlent, boîte de dialogue en jeu
- "ℹ️ 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:
co-authored by
Claude Sonnet 5
parent
390ee831ca
commit
2347a70c45
+2
-2
@@ -79,7 +79,7 @@ from .custom_events.create_custom_event import create_custom_event
|
|||||||
from .custom_events.update_custom_event import update_custom_event
|
from .custom_events.update_custom_event import update_custom_event
|
||||||
|
|
||||||
from .quests.constants import (
|
from .quests.constants import (
|
||||||
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS, QUEST_SPEAKER_CHOICES,
|
QUEST_STATUS_CHOICES, QUEST_STATUS_LABELS, QUEST_RESULT_CHOICES, QUEST_RESULT_LABELS,
|
||||||
)
|
)
|
||||||
from .quests.list_quests import list_quests
|
from .quests.list_quests import list_quests
|
||||||
from .quests.get_quest import get_quest
|
from .quests.get_quest import get_quest
|
||||||
@@ -106,7 +106,7 @@ __all__ = [
|
|||||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||||
"delete_global_variable_by_id",
|
"delete_global_variable_by_id",
|
||||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
||||||
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS", "QUEST_SPEAKER_CHOICES",
|
"QUEST_STATUS_CHOICES", "QUEST_STATUS_LABELS", "QUEST_RESULT_CHOICES", "QUEST_RESULT_LABELS",
|
||||||
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
"list_quests", "get_quest", "create_quest", "update_quest", "delete_quest",
|
||||||
"set_quest_dialogues", "sanitize_quest_dialogues",
|
"set_quest_dialogues", "sanitize_quest_dialogues",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
|
|||||||
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
|
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
|
||||||
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
|
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
|
||||||
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
|
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
|
||||||
QUEST_SPEAKER_CHOICES = ["joueur", "pnj"]
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES
|
from .constants import QUEST_STATUS_CHOICES
|
||||||
|
|
||||||
_MAX_LINES_PER_COLUMN = 200
|
_MAX_LINES_PER_COLUMN = 200
|
||||||
|
_MAX_SPEAKER_LENGTH = 60
|
||||||
|
|
||||||
|
|
||||||
def sanitize_quest_dialogues(raw_dialogues):
|
def sanitize_quest_dialogues(raw_dialogues):
|
||||||
"""Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne
|
"""Une réplique = {speaker: '<nom du personnage>', text: '...'} — le
|
||||||
par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que
|
nom vient du champ "ℹ️ Informations" d'un objet de scène kind=
|
||||||
resolve_collision_rules.sanitize_collision_rules : rejette tout
|
"personnage" (voir screens/rendering/personnage_names.py), ou
|
||||||
élément invalide plutôt que de lever, ne garde jamais un texte vide."""
|
"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.
|
||||||
|
Une colonne par statut de quête (voir QUEST_STATUS_CHOICES), même
|
||||||
|
esprit que resolve_collision_rules.sanitize_collision_rules : rejette
|
||||||
|
tout élément invalide plutôt que de lever, ne garde jamais un texte
|
||||||
|
ou un nom vide."""
|
||||||
result = {status: [] for status in QUEST_STATUS_CHOICES}
|
result = {status: [] for status in QUEST_STATUS_CHOICES}
|
||||||
if not isinstance(raw_dialogues, dict):
|
if not isinstance(raw_dialogues, dict):
|
||||||
return result
|
return result
|
||||||
@@ -21,10 +28,10 @@ def sanitize_quest_dialogues(raw_dialogues):
|
|||||||
continue
|
continue
|
||||||
speaker = raw_line.get("speaker")
|
speaker = raw_line.get("speaker")
|
||||||
text = raw_line.get("text")
|
text = raw_line.get("text")
|
||||||
if speaker not in QUEST_SPEAKER_CHOICES:
|
if not isinstance(speaker, str) or not speaker.strip():
|
||||||
continue
|
continue
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
continue
|
continue
|
||||||
cleaned.append({"speaker": speaker, "text": text.strip()})
|
cleaned.append({"speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()})
|
||||||
result[status] = cleaned
|
result[status] = cleaned
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ from . import (
|
|||||||
scene_object_role,
|
scene_object_role,
|
||||||
scene_object_collision,
|
scene_object_collision,
|
||||||
scene_object_collision_rules,
|
scene_object_collision_rules,
|
||||||
|
scene_object_name,
|
||||||
|
scene_object_dialogue_box_style,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,13 +26,17 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
personnage_commands = None
|
personnage_commands = None
|
||||||
personnage_role = None
|
personnage_role = None
|
||||||
collision_settings = None
|
collision_settings = None
|
||||||
|
dialogue_box_style = None
|
||||||
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
|
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
|
||||||
if selected_obj:
|
if selected_obj:
|
||||||
|
if selected_obj["kind"] != "dialogue_box":
|
||||||
collision_settings = screens.resolve_collision_settings(selected_obj)
|
collision_settings = screens.resolve_collision_settings(selected_obj)
|
||||||
if selected_obj["kind"] == "personnage":
|
if selected_obj["kind"] == "personnage":
|
||||||
personnage_data = screens.resolve_personnage_data(selected_obj)
|
personnage_data = screens.resolve_personnage_data(selected_obj)
|
||||||
personnage_commands = screens.resolve_personnage_commands(selected_obj)
|
personnage_commands = screens.resolve_personnage_commands(selected_obj)
|
||||||
personnage_role = screens.resolve_personnage_role(selected_obj)
|
personnage_role = screens.resolve_personnage_role(selected_obj)
|
||||||
|
elif selected_obj["kind"] == "dialogue_box":
|
||||||
|
dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj)
|
||||||
|
|
||||||
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||||
object_labels = {
|
object_labels = {
|
||||||
@@ -52,7 +56,7 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
|
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
|
||||||
collision_rule_objects = []
|
collision_rule_objects = []
|
||||||
for o in objects:
|
for o in objects:
|
||||||
if o["kind"] == "fond":
|
if o["kind"] in ("fond", "dialogue_box"):
|
||||||
continue
|
continue
|
||||||
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
|
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
|
||||||
if role == "joueur":
|
if role == "joueur":
|
||||||
@@ -95,6 +99,8 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
|
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
|
||||||
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
|
personnage_role_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
|
||||||
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
|
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
|
||||||
|
dialogue_box_style=dialogue_box_style or screens.DEFAULT_DIALOGUE_BOX_STYLE,
|
||||||
|
dialogue_box_font_weight_choices=screens.FONT_WEIGHT_CHOICES,
|
||||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||||
sprite_library_json=json.dumps(sprite_library),
|
sprite_library_json=json.dumps(sprite_library),
|
||||||
background_library=background_library,
|
background_library=background_library,
|
||||||
@@ -134,4 +140,8 @@ def render_scene_edit(slug, screen_id, game):
|
|||||||
# existante, sans attendre l'ouverture de l'assistant "+ Action"
|
# existante, sans attendre l'ouverture de l'assistant "+ Action"
|
||||||
# (seul moment où questPickerHtml irait la chercher sinon).
|
# (seul moment où questPickerHtml irait la chercher sinon).
|
||||||
quests_json=json.dumps(db.list_quests(slug)),
|
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)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/dialogue-box-style", methods=["POST"])
|
||||||
|
def scene_object_dialogue_box_style(slug, object_id):
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
style = screens.set_scene_object_dialogue_box_style(slug, object_id, body.get("style"))
|
||||||
|
if style is None:
|
||||||
|
return jsonify({"error": "objet introuvable"}), 404
|
||||||
|
return jsonify({"style": style})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/scene-objects/<int:object_id>/name", methods=["POST"])
|
||||||
|
def scene_object_name(slug, object_id):
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
screens.set_scene_object_name(slug, object_id, data.get("name"))
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -88,6 +88,8 @@ from .rendering.collision_rules import (
|
|||||||
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
|
ACTION_TYPES, TRIGGER_TYPES, DEFAULT_PERIMETRE_PX,
|
||||||
)
|
)
|
||||||
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
||||||
|
from .rendering.personnage_names import list_personnage_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
|
from .screens_repo.ensure_schema import ensure_schema
|
||||||
from .screens_repo.list_screens import list_screens
|
from .screens_repo.list_screens import list_screens
|
||||||
@@ -125,6 +127,8 @@ from .scenes.set_scene_object_commands import set_scene_object_commands
|
|||||||
from .scenes.set_scene_object_role import set_scene_object_role
|
from .scenes.set_scene_object_role import set_scene_object_role
|
||||||
from .scenes.set_scene_object_collision import set_scene_object_collision
|
from .scenes.set_scene_object_collision import set_scene_object_collision
|
||||||
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
|
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
|
||||||
|
from .scenes.set_scene_object_name import set_scene_object_name
|
||||||
|
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||||
from .scenes.render_scene_object import render_scene_object
|
from .scenes.render_scene_object import render_scene_object
|
||||||
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
from .scenes.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
|
||||||
from .elements.element_label import element_label
|
from .elements.element_label import element_label
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ..rendering.personnage_commands import resolve_personnage_commands
|
|||||||
from ..rendering.personnage_role import resolve_personnage_role
|
from ..rendering.personnage_role import resolve_personnage_role
|
||||||
from ..rendering.collision_settings import resolve_collision_settings
|
from ..rendering.collision_settings import resolve_collision_settings
|
||||||
from ..rendering.collision_rules import resolve_collision_rules
|
from ..rendering.collision_rules import resolve_collision_rules
|
||||||
|
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||||
from ..scenes.list_scene_objects import list_scene_objects
|
from ..scenes.list_scene_objects import list_scene_objects
|
||||||
from ..scenes.render_scene_object import render_scene_object
|
from ..scenes.render_scene_object import render_scene_object
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
if o["kind"] == "personnage":
|
if o["kind"] == "personnage":
|
||||||
o["personnage_commandes"] = resolve_personnage_commands(o)
|
o["personnage_commandes"] = resolve_personnage_commands(o)
|
||||||
o["personnage_role"] = resolve_personnage_role(o)
|
o["personnage_role"] = resolve_personnage_role(o)
|
||||||
|
if o["kind"] == "dialogue_box":
|
||||||
|
# Widget d'interface (voir "🖥️ Interface",
|
||||||
|
# screens/rendering/dialogue_box_style.py) : ni
|
||||||
|
# collision, ni cible/source de règle — juste son
|
||||||
|
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||||
|
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||||
|
continue
|
||||||
# Boîte de collision (voir static/js/play/conditions.js::
|
# Boîte de collision (voir static/js/play/conditions.js::
|
||||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||||
# seulement "personnage" — un décor/fond peut aussi servir
|
# seulement "personnage" — un décor/fond peut aussi servir
|
||||||
@@ -198,9 +206,18 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
|||||||
# ci-dessus sont déjà indexés par id, jamais par nom.
|
# ci-dessus sont déjà indexés par id, jamais par nom.
|
||||||
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
definition_names = {str(d["id"]): d["name"] for d in definitions}
|
||||||
|
|
||||||
|
# Quêtes (voir db/quests/, onglet "🗺️ Quêtes") : GAME-WIDE, jamais
|
||||||
|
# rattachées à un écran comme "elements" ci-dessus — nécessaires ici
|
||||||
|
# pour que static/js/play/dialogue-box-controller.js puisse retrouver,
|
||||||
|
# à l'exécution d'une action "quete" (collision-rules-controller.js),
|
||||||
|
# le dialogue de la colonne correspondant au STATUT ACTUEL de la
|
||||||
|
# quête (nouvelle/en_cours/terminee) sans aller-retour serveur.
|
||||||
|
quests = db.list_quests(slug)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||||
"variables": variables, "personnage_animations": personnage_animations,
|
"variables": variables, "personnage_animations": personnage_animations,
|
||||||
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
"definition_names": definition_names, "element_type_templates": element_type_templates,
|
||||||
|
"quests": quests,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
# Widget d'interface "💬 Boîte de dialogue" (voir "🖥️ Interface",
|
||||||
|
# templates/scene_edit.html, 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 (nom du
|
||||||
|
# personnage qui parle), corps (texte), pied (bouton "Suivant"),
|
||||||
|
# entièrement stylisable par l'auteur.
|
||||||
|
DEFAULT_DIALOGUE_BOX_STYLE = {
|
||||||
|
"font_family": "inherit",
|
||||||
|
"font_size": 14,
|
||||||
|
"font_weight": "normal",
|
||||||
|
"text_color": "#ffffff",
|
||||||
|
"header_bg": "#2f6fed",
|
||||||
|
"body_bg": "#1a1d24",
|
||||||
|
"footer_bg": "#11141b",
|
||||||
|
}
|
||||||
|
|
||||||
|
FONT_WEIGHT_CHOICES = ["normal", "bold"]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_dialogue_box_style(raw_style):
|
||||||
|
style = dict(DEFAULT_DIALOGUE_BOX_STYLE)
|
||||||
|
if not isinstance(raw_style, dict):
|
||||||
|
return style
|
||||||
|
if isinstance(raw_style.get("font_family"), str) and raw_style["font_family"].strip():
|
||||||
|
style["font_family"] = raw_style["font_family"].strip()[:60]
|
||||||
|
try:
|
||||||
|
style["font_size"] = max(8, min(48, int(raw_style.get("font_size"))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if raw_style.get("font_weight") in FONT_WEIGHT_CHOICES:
|
||||||
|
style["font_weight"] = raw_style["font_weight"]
|
||||||
|
for key in ("text_color", "header_bg", "body_bg", "footer_bg"):
|
||||||
|
value = raw_style.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
style[key] = value.strip()[:20]
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dialogue_box_style(obj):
|
||||||
|
raw = (obj.get("attributes") or {}).get("_dialogue_box_style")
|
||||||
|
if not raw:
|
||||||
|
return dict(DEFAULT_DIALOGUE_BOX_STYLE)
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return dict(DEFAULT_DIALOGUE_BOX_STYLE)
|
||||||
|
return sanitize_dialogue_box_style(parsed)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
@@ -5,7 +5,11 @@ import db
|
|||||||
from .ensure_scene_schema import ensure_scene_schema
|
from .ensure_scene_schema import ensure_scene_schema
|
||||||
|
|
||||||
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
_DEFAULT_PERSONNAGE_DATA = {"source": "forge", "forge_character": "male-adventurer"}
|
||||||
_KINDS = {"personnage", "decor", "fond"}
|
_KINDS = {"personnage", "decor", "fond", "dialogue_box"}
|
||||||
|
# Widget d'interface (voir "🖥️ Interface", screens/rendering/dialogue_box_style.py) :
|
||||||
|
# assez grand pour lire une réplique sans redimensionner, jamais confondu
|
||||||
|
# visuellement avec un personnage/décor (128×128 par défaut).
|
||||||
|
_DIALOGUE_BOX_DEFAULT_SIZE = (320, 150)
|
||||||
|
|
||||||
|
|
||||||
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
|
def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, background_slug=None):
|
||||||
@@ -40,6 +44,8 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
|
|||||||
if background:
|
if background:
|
||||||
attributes["src"] = background["url"]
|
attributes["src"] = background["url"]
|
||||||
width, height = background["width"], background["height"]
|
width, height = background["width"], background["height"]
|
||||||
|
elif kind == "dialogue_box":
|
||||||
|
width, height = _DIALOGUE_BOX_DEFAULT_SIZE
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
if kind == "fond":
|
if kind == "fond":
|
||||||
z_index = -1000
|
z_index = -1000
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import html as html_lib
|
import html as html_lib
|
||||||
|
|
||||||
from ..rendering.personnage_data import resolve_personnage_idle_frame
|
from ..rendering.personnage_data import resolve_personnage_idle_frame
|
||||||
|
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||||
|
|
||||||
|
|
||||||
def render_scene_object(obj):
|
def render_scene_object(obj):
|
||||||
@@ -39,6 +40,31 @@ def render_scene_object(obj):
|
|||||||
# remplirait quand même son cadre (width/height:100%, voir la règle
|
# remplirait quand même son cadre (width/height:100%, voir la règle
|
||||||
# `.canvasElementInner > img` dans scene_edit.html) mais DÉFORMÉE,
|
# `.canvasElementInner > img` dans scene_edit.html) mais DÉFORMÉE,
|
||||||
# rendant le personnage minuscule/écrasé dans sa boîte de sélection.
|
# rendant le personnage minuscule/écrasé dans sa boîte de sélection.
|
||||||
|
if obj["kind"] == "dialogue_box":
|
||||||
|
# Widget d'interface (voir "🖥️ Interface" + screens/rendering/
|
||||||
|
# dialogue_box_style.py) : un <div> à 3 zones, pas un <img> — voir
|
||||||
|
# static/js/play/dialogue-box-controller.js pour comment header/
|
||||||
|
# body/footer sont remplis EN JEU (data-dialogue-role sert de
|
||||||
|
# sélecteur), masqué par défaut au jeu (voir static/style.css,
|
||||||
|
# `.playScreen .dialogueBoxWidget`) jusqu'à ce qu'une règle de
|
||||||
|
# collision "quete" ait un dialogue à afficher. Toujours VISIBLE
|
||||||
|
# dans l'éditeur (avec un texte d'exemple) pour pouvoir le
|
||||||
|
# positionner/styliser.
|
||||||
|
style = resolve_dialogue_box_style(obj)
|
||||||
|
box_style = (
|
||||||
|
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
||||||
|
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
|
||||||
|
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
|
||||||
|
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'<div class="dialogueBoxWidget" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||||
|
f'<div class="dialogueBoxHeader" data-dialogue-role="header" style="background:{html_lib.escape(style["header_bg"])}">Nom du personnage</div>'
|
||||||
|
f'<div class="dialogueBoxBody" data-dialogue-role="body" style="background:{html_lib.escape(style["body_bg"])}">Le texte du dialogue s\'affiche ici.</div>'
|
||||||
|
f'<div class="dialogueBoxFooter" style="background:{html_lib.escape(style["footer_bg"])}">'
|
||||||
|
f'<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance({obj["id"]})">Suivant →</button>'
|
||||||
|
f'</div></div>'
|
||||||
|
)
|
||||||
if obj["kind"] == "personnage":
|
if obj["kind"] == "personnage":
|
||||||
src = resolve_personnage_idle_frame(obj)
|
src = resolve_personnage_idle_frame(obj)
|
||||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from ..rendering.dialogue_box_style import sanitize_dialogue_box_style
|
||||||
|
from .ensure_scene_schema import ensure_scene_schema
|
||||||
|
|
||||||
|
|
||||||
|
def set_scene_object_dialogue_box_style(slug, object_id, raw_style):
|
||||||
|
ensure_scene_schema(slug)
|
||||||
|
conn = db.connect(slug)
|
||||||
|
row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
attributes = json.loads(row["attributes"] or "{}")
|
||||||
|
style = sanitize_dialogue_box_style(raw_style)
|
||||||
|
attributes["_dialogue_box_style"] = json.dumps(style)
|
||||||
|
conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return style
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import db
|
||||||
|
|
||||||
|
from .ensure_scene_schema import ensure_scene_schema
|
||||||
|
|
||||||
|
|
||||||
|
def set_scene_object_name(slug, object_id, name):
|
||||||
|
"""Enregistre le nom d'un objet de scène (colonne `name`, voir
|
||||||
|
ensure_scene_schema.py — déjà utilisée comme LIBELLÉ d'affichage
|
||||||
|
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/
|
||||||
|
quests/quest-editor.js)."""
|
||||||
|
ensure_scene_schema(slug)
|
||||||
|
conn = db.connect(slug)
|
||||||
|
conn.execute("UPDATE _scene_objects SET name = ? WHERE id = ?", ((name or "").strip() or None, object_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -71,10 +71,13 @@ test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEve
|
|||||||
assert.deepEqual(calls, [42]);
|
assert.deepEqual(calls, [42]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forgeRunCollisionRuleAction — "quete" mémorise l\'id dans window.forgeQuestLog (pas d\'éditeur de quête pour le moment)', () => {
|
test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => {
|
||||||
window.forgeQuestLog = [];
|
const calls = [];
|
||||||
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 'q-1' }, fakeEl(), { id: 1 });
|
const previousShow = global.forgeShowDialogueBox;
|
||||||
assert.deepEqual(window.forgeQuestLog, ['q-1']);
|
global.forgeShowDialogueBox = id => calls.push(id);
|
||||||
|
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 42 }, fakeEl(), { id: 1 });
|
||||||
|
global.forgeShowDialogueBox = previousShow;
|
||||||
|
assert.deepEqual(calls, [42]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
|
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Widget "💬 Boîte de dialogue" — moteur d'exécution (voir
|
||||||
|
// static/js/play/dialogue-box-controller.js) : affiche la réplique en
|
||||||
|
// cours du dialogue d'une quête (gameData.quests) correspondant à son
|
||||||
|
// STATUT ACTUEL, avance au clic sur "Suivant", se masque à la fin.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
global.window = global.window || {};
|
||||||
|
global.gameData = {};
|
||||||
|
|
||||||
|
const {
|
||||||
|
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||||
|
} = require('../dialogue-box-controller.js');
|
||||||
|
|
||||||
|
function fakeWidget(objectId) {
|
||||||
|
const roles = {
|
||||||
|
header: { textContent: '' },
|
||||||
|
body: { textContent: '' },
|
||||||
|
};
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
|
||||||
|
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||||
|
querySelector: sel => {
|
||||||
|
if (sel === '[data-dialogue-role="header"]') return roles.header;
|
||||||
|
if (sel === '[data-dialogue-role="body"]') return roles.body;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
_roles: roles, _classes: classes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
global.window.currentScreenId = 1;
|
||||||
|
global.gameData = {
|
||||||
|
quests: [
|
||||||
|
{ id: 5, status: 'nouvelle', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
|
||||||
|
{ id: 6, status: 'en_cours', dialogues: { nouvelle: [{ speaker: 'PNJ', text: 'x' }], en_cours: [], terminee: [] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgeQuestById retrouve une quête par id', () => {
|
||||||
|
assert.equal(forgeQuestById(5).id, 5);
|
||||||
|
assert.equal(forgeQuestById(999), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgeShowDialogueBox — quête introuvable : aucun effet (pas de plantage)', () => {
|
||||||
|
const previousDoc = global.document;
|
||||||
|
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => forgeShowDialogueBox(999));
|
||||||
|
} finally { global.document = previousDoc; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgeShowDialogueBox — quête sans réplique pour son statut actuel : aucun effet', () => {
|
||||||
|
const previousDoc = global.document;
|
||||||
|
global.document = { querySelector: () => { throw new Error('ne devrait jamais chercher de widget'); } };
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => forgeShowDialogueBox(6)); // "en_cours" -> dialogues.en_cours est vide
|
||||||
|
} finally { global.document = previousDoc; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet', () => {
|
||||||
|
const previousDoc = global.document;
|
||||||
|
global.document = { querySelector: () => null };
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => forgeShowDialogueBox(5));
|
||||||
|
} finally { global.document = previousDoc; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
|
||||||
|
const widget = fakeWidget(42);
|
||||||
|
const previousDoc = global.document;
|
||||||
|
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||||
|
try {
|
||||||
|
forgeShowDialogueBox(5);
|
||||||
|
assert.equal(widget._roles.header.textContent, 'Le Sorcier');
|
||||||
|
assert.equal(widget._roles.body.textContent, 'Bonjour !');
|
||||||
|
assert.equal(widget._classes.has('is-active'), true);
|
||||||
|
|
||||||
|
forgeDialogueBoxAdvance(42);
|
||||||
|
assert.equal(widget._roles.header.textContent, 'Joueur');
|
||||||
|
assert.equal(widget._roles.body.textContent, 'Salut.');
|
||||||
|
assert.equal(widget._classes.has('is-active'), true); // encore une réplique visible
|
||||||
|
|
||||||
|
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> se masque
|
||||||
|
assert.equal(widget._classes.has('is-active'), false);
|
||||||
|
} finally { global.document = previousDoc; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet', () => {
|
||||||
|
const previousDoc = global.document;
|
||||||
|
global.document = { querySelector: () => null };
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => forgeDialogueBoxAdvance(999));
|
||||||
|
} finally { global.document = previousDoc; }
|
||||||
|
});
|
||||||
@@ -80,10 +80,12 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action.type === 'quete') {
|
if (action.type === 'quete') {
|
||||||
// Éditeur de quête pas encore construit (voir plan) — mémorise
|
// Affiche la réplique en cours du dialogue de cette quête, selon son
|
||||||
// l'intention pour un futur système de quêtes plutôt que de la perdre.
|
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur
|
||||||
window.forgeQuestLog = window.forgeQuestLog || [];
|
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans
|
||||||
window.forgeQuestLog.push(action.quete_id);
|
// effet si aucun widget n'est posé ou si la quête n'a aucune
|
||||||
|
// réplique pour son statut, comme le reste de ce moteur.
|
||||||
|
forgeShowDialogueBox(action.quete_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,4 +187,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
|||||||
const personnageMod = require('./personnage-controller.js');
|
const personnageMod = require('./personnage-controller.js');
|
||||||
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
|
||||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||||
|
const dialogueBoxMod = require('./dialogue-box-controller.js');
|
||||||
|
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// ---------- Widget "💬 Boîte de dialogue" — moteur d'exécution ----------
|
||||||
|
// Fait le lien entre l'éditeur de collision (action "quete", voir
|
||||||
|
// static/js/play/collision-rules-controller.js) et le widget d'interface
|
||||||
|
// posé par l'auteur (voir "🖥️ Interface", templates/scene_edit.html,
|
||||||
|
// screens/rendering/dialogue_box_style.py) : à l'exécution d'une action
|
||||||
|
// "quete", affiche la réplique en cours du dialogue correspondant au
|
||||||
|
// STATUT ACTUEL de la quête (gameData.quests, voir full_game_payload.py),
|
||||||
|
// avance d'une réplique à chaque clic sur "Suivant", se masque à la fin.
|
||||||
|
// Silencieux (aucun effet) si la quête est introuvable, n'a aucune
|
||||||
|
// réplique pour son statut, ou si l'auteur n'a posé aucun widget sur
|
||||||
|
// l'écran — même esprit que le reste du moteur (jamais planter).
|
||||||
|
|
||||||
|
var forgeDialogueBoxState = {};
|
||||||
|
|
||||||
|
function forgeQuestById(questId) {
|
||||||
|
return (gameData.quests || []).find(function (q) { return q.id === questId; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||||||
|
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDialogueBoxWidgetById(objectId) {
|
||||||
|
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeRenderDialogueBoxLine(widget, line) {
|
||||||
|
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||||
|
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||||
|
if (header) header.textContent = line.speaker;
|
||||||
|
if (body) body.textContent = line.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appelée par l'action "quete" (collision-rules-controller.js) — trouve
|
||||||
|
// le widget posé sur l'écran ACTUELLEMENT affiché (un seul à la fois,
|
||||||
|
// comme le reste du moteur 2D).
|
||||||
|
function forgeShowDialogueBox(questId) {
|
||||||
|
var quest = forgeQuestById(questId);
|
||||||
|
if (!quest) return;
|
||||||
|
var lines = (quest.dialogues && quest.dialogues[quest.status]) || [];
|
||||||
|
if (!lines.length) return;
|
||||||
|
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||||
|
if (!widget) return;
|
||||||
|
var objectId = widget.getAttribute('data-object-id');
|
||||||
|
forgeDialogueBoxState[objectId] = { lines: lines, index: 0 };
|
||||||
|
forgeRenderDialogueBoxLine(widget, lines[0]);
|
||||||
|
widget.classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appelée par le bouton "Suivant →" rendu dans le widget lui-même (voir
|
||||||
|
// render_scene_object.py, onclick="forgeDialogueBoxAdvance(<id>)").
|
||||||
|
function forgeDialogueBoxAdvance(objectId) {
|
||||||
|
var state = forgeDialogueBoxState[objectId];
|
||||||
|
var widget = forgeDialogueBoxWidgetById(objectId);
|
||||||
|
if (!state || !widget) return;
|
||||||
|
state.index += 1;
|
||||||
|
if (state.index >= state.lines.length) {
|
||||||
|
widget.classList.remove('is-active');
|
||||||
|
delete forgeDialogueBoxState[objectId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forgeRenderDialogueBoxLine(widget, state.lines[state.index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// static/js/play/__tests__/ (node:test) — même garde que les autres
|
||||||
|
// contrôleurs de ce dossier.
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
|
||||||
|
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
|||||||
// se déplace lui-même.
|
// se déplace lui-même.
|
||||||
function forgeSolidObstacles(screenData, movingId) {
|
function forgeSolidObstacles(screenData, movingId) {
|
||||||
return (screenData.elements || []).filter(function (o) {
|
return (screenData.elements || []).filter(function (o) {
|
||||||
return o.id !== movingId && o.kind !== 'fond' && !(o.collision && o.collision.enabled === false);
|
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && !(o.collision && o.collision.enabled === false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
|
|||||||
// besoin (collisionRuleActionSummary).
|
// besoin (collisionRuleActionSummary).
|
||||||
var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : [];
|
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 : [];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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) {
|
||||||
|
return speaker === 'Joueur' ? 'joueur' : 'pnj';
|
||||||
|
}
|
||||||
|
|
||||||
function questById(questId) {
|
function questById(questId) {
|
||||||
return QUESTS.find(function (q) { return q.id === questId; });
|
return QUESTS.find(function (q) { return q.id === questId; });
|
||||||
}
|
}
|
||||||
@@ -178,13 +192,26 @@ function renderQuestFieldsModalBody(questId) {
|
|||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
|
||||||
|
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
|
||||||
|
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
|
||||||
|
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
|
||||||
|
// valeur déjà enregistrée.
|
||||||
|
function questSpeakerOptions(currentSpeaker) {
|
||||||
|
var options = ['Joueur'].concat(PERSONNAGE_NAMES);
|
||||||
|
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
function questDialogueBubbleHtml(line, status, index) {
|
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 (
|
return (
|
||||||
'<div class="questBubbleRow questBubbleRow-' + line.speaker + '">' +
|
'<div class="questBubbleRow questBubbleRow-' + kind + '">' +
|
||||||
'<div class="questBubble questBubble-' + line.speaker + '">' +
|
'<div class="questBubble questBubble-' + kind + '">' +
|
||||||
'<div class="questBubbleSpeaker" onclick="questToggleSpeaker(\'' + status + '\', ' + index + ')" title="Changer qui parle">' +
|
'<select class="questBubbleSpeaker" title="Qui parle" onchange="questSetBubbleSpeaker(\'' + status + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
|
||||||
(line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') +
|
|
||||||
'</div>' +
|
|
||||||
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
|
||||||
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
|
||||||
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
'onchange="questUpdateBubbleText(\'' + status + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '<') + '</textarea>' +
|
||||||
@@ -292,22 +319,30 @@ function questSaveDialogues(quest) {
|
|||||||
.then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; });
|
.then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de
|
||||||
|
// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations")
|
||||||
|
// — 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';
|
||||||
|
return 'Joueur';
|
||||||
|
}
|
||||||
|
|
||||||
function questAddBubble(status) {
|
function questAddBubble(status) {
|
||||||
var quest = questById(_currentQuestModalId());
|
var quest = questById(_currentQuestModalId());
|
||||||
if (!quest) return;
|
if (!quest) return;
|
||||||
quest.dialogues = quest.dialogues || {};
|
quest.dialogues = quest.dialogues || {};
|
||||||
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
|
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
|
||||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'pnj';
|
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
|
||||||
lines.push({ speaker: lastSpeaker === 'pnj' ? 'joueur' : 'pnj', text: '' });
|
lines.push({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||||
renderQuestDialogueModalBody(quest.id);
|
renderQuestDialogueModalBody(quest.id);
|
||||||
questSaveDialogues(quest);
|
questSaveDialogues(quest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function questToggleSpeaker(status, index) {
|
function questSetBubbleSpeaker(status, index, speaker) {
|
||||||
var quest = questById(_currentQuestModalId());
|
var quest = questById(_currentQuestModalId());
|
||||||
if (!quest) return;
|
if (!quest) return;
|
||||||
var line = quest.dialogues[status][index];
|
quest.dialogues[status][index].speaker = speaker;
|
||||||
line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur';
|
|
||||||
renderQuestDialogueModalBody(quest.id);
|
renderQuestDialogueModalBody(quest.id);
|
||||||
questSaveDialogues(quest);
|
questSaveDialogues(quest);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,47 @@ function addPersonnageCommandExtraRow() {
|
|||||||
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
|
||||||
|
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue de
|
||||||
|
// quête (voir static/js/quests/quest-editor.js).
|
||||||
|
function saveSceneObjectName(objectId, name) {
|
||||||
|
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "🎨 Style" du widget "💬 Boîte de dialogue" (voir screens/rendering/
|
||||||
|
// dialogue_box_style.py) — envoie TOUJOURS les 7 champs ensemble (même
|
||||||
|
// convention "replace-all" que saveSceneObjectCollision), jamais un seul
|
||||||
|
// champ isolé.
|
||||||
|
function saveDialogueBoxStyle(objectId) {
|
||||||
|
var style = {
|
||||||
|
font_family: document.getElementById('dbox-font-family').value,
|
||||||
|
font_size: Number(document.getElementById('dbox-font-size').value),
|
||||||
|
font_weight: document.getElementById('dbox-font-weight').value,
|
||||||
|
text_color: document.getElementById('dbox-text-color').value,
|
||||||
|
header_bg: document.getElementById('dbox-header-bg').value,
|
||||||
|
body_bg: document.getElementById('dbox-body-bg').value,
|
||||||
|
footer_bg: document.getElementById('dbox-footer-bg').value,
|
||||||
|
};
|
||||||
|
var el = document.getElementById('el-' + objectId);
|
||||||
|
var widget = el && el.querySelector('.dialogueBoxWidget');
|
||||||
|
if (widget) {
|
||||||
|
widget.style.fontFamily = style.font_family;
|
||||||
|
widget.style.fontSize = style.font_size + 'px';
|
||||||
|
widget.style.fontWeight = style.font_weight;
|
||||||
|
widget.style.color = style.text_color;
|
||||||
|
var header = widget.querySelector('.dialogueBoxHeader'); if (header) header.style.background = style.header_bg;
|
||||||
|
var b = widget.querySelector('.dialogueBoxBody'); if (b) b.style.background = style.body_bg;
|
||||||
|
var footer = widget.querySelector('.dialogueBoxFooter'); if (footer) footer.style.background = style.footer_bg;
|
||||||
|
}
|
||||||
|
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/dialogue-box-style', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ style: style }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
var body = document.getElementById('personnageCommandsBody');
|
var body = document.getElementById('personnageCommandsBody');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|||||||
+30
-1
@@ -634,6 +634,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
|||||||
comporte donc exactement comme avant. */
|
comporte donc exactement comme avant. */
|
||||||
.playScreen.playScene{ overflow:hidden; }
|
.playScreen.playScene{ overflow:hidden; }
|
||||||
.sceneWorld{ position:relative; width:100%; height:100%; }
|
.sceneWorld{ position:relative; width:100%; height:100%; }
|
||||||
|
/* .sceneUI : couche d'interface FIXE au VIEWPORT (jamais dans
|
||||||
|
.sceneWorld, qui défile avec la caméra, voir static/js/play/
|
||||||
|
personnage-controller.js) — le widget "💬 Boîte de dialogue" y vit,
|
||||||
|
voir templates/play.html. */
|
||||||
|
.sceneUI{ position:absolute; inset:0; pointer-events:none; z-index:500; }
|
||||||
|
|
||||||
|
/* Widget "💬 Boîte de dialogue" (voir screens/rendering/dialogue_box_style.py,
|
||||||
|
static/js/play/dialogue-box-controller.js) : header/body/footer,
|
||||||
|
toujours VISIBLE dans l'éditeur (texte d'exemple, pour le positionner/
|
||||||
|
styliser) mais masqué par défaut EN JEU tant qu'aucune règle de
|
||||||
|
collision "quete" n'a de réplique à afficher. */
|
||||||
|
.dialogueBoxWidget{
|
||||||
|
display:flex; flex-direction:column; border-radius:10px; overflow:hidden;
|
||||||
|
box-shadow:0 6px 18px rgba(0,0,0,.35); pointer-events:auto;
|
||||||
|
}
|
||||||
|
.playScreen .dialogueBoxWidget{ display:none; }
|
||||||
|
.playScreen .dialogueBoxWidget.is-active{ display:flex; }
|
||||||
|
.dialogueBoxHeader{ padding:8px 12px; font-weight:700; }
|
||||||
|
.dialogueBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; }
|
||||||
|
.dialogueBoxFooter{ padding:6px 10px; display:flex; justify-content:flex-end; }
|
||||||
|
.dialogueBoxNextBtn{
|
||||||
|
background:rgba(255,255,255,.15); color:inherit; border:1px solid rgba(255,255,255,.3);
|
||||||
|
border-radius:6px; padding:5px 12px; font:inherit; font-size:12px; cursor:pointer;
|
||||||
|
}
|
||||||
|
.dialogueBoxNextBtn:hover{ background:rgba(255,255,255,.28); }
|
||||||
|
|
||||||
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
|
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
|
||||||
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir
|
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir
|
||||||
@@ -804,7 +829,11 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
|||||||
}
|
}
|
||||||
.questBubble-pnj{ background:linear-gradient(135deg, #2f6fed, #1e4fb0); border-bottom-left-radius:4px; }
|
.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; }
|
.questBubble-joueur{ background:linear-gradient(135deg, #2ecc71, #1e9e57); border-bottom-right-radius:4px; }
|
||||||
.questBubbleSpeaker{ font-size:10.5px; font-weight:700; color:rgba(255,255,255,.85); cursor:pointer; }
|
.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%;
|
||||||
|
}
|
||||||
|
.questBubbleSpeaker option{ color:#111; }
|
||||||
.questBubbleText{
|
.questBubbleText{
|
||||||
resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px;
|
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;
|
||||||
|
|||||||
+8
-1
@@ -84,7 +84,13 @@
|
|||||||
uniquement si un objet kind="fond" plus grand que la scène est posé
|
uniquement si un objet kind="fond" plus grand que la scène est posé
|
||||||
(caméra qui suit le personnage). -->
|
(caméra qui suit le personnage). -->
|
||||||
<div class="sceneWorld" data-scene-width="{{ s.scene_width }}" data-scene-height="{{ s.scene_height }}">
|
<div class="sceneWorld" data-scene-width="{{ s.scene_width }}" data-scene-height="{{ s.scene_height }}">
|
||||||
{% for obj in s.elements %}{{ obj.rendered_html|safe }}{% endfor %}
|
{% for obj in s.elements if obj.kind != 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
<!-- .sceneUI : widgets d'interface FIXES au viewport, jamais dans
|
||||||
|
.sceneWorld ci-dessus (voir static/style.css) — pour l'instant
|
||||||
|
seulement "💬 Boîte de dialogue" (screens/rendering/dialogue_box_style.py). -->
|
||||||
|
<div class="sceneUI">
|
||||||
|
{% for obj in s.elements if obj.kind == 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -155,6 +161,7 @@
|
|||||||
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
|
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
|
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
|
||||||
|
<script src="{{ asset_url('js/play/dialogue-box-controller.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
|
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
|
<script src="{{ asset_url('js/play/bindings.js') }}"></script>
|
||||||
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
|
<script src="{{ asset_url('js/play/flow-engine.js') }}"></script>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
#canvas (son ancêtre positionné le plus proche), en double du
|
#canvas (son ancêtre positionné le plus proche), en double du
|
||||||
décalage déjà appliqué par .canvasElement. */
|
décalage déjà appliqué par .canvasElement. */
|
||||||
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
||||||
|
/* Même correctif que ci-dessus, pour le widget "💬 Boîte de dialogue"
|
||||||
|
(screens/scenes/render_scene_object.py, un <div>, pas un <img>). */
|
||||||
|
.canvasElementInner > .dialogueBoxWidget{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -87,6 +90,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<!-- "🖥️ Interface" — l'UI du jeu (voir screens/rendering/
|
||||||
|
dialogue_box_style.py) : pour l'instant un seul widget, "💬 Boîte
|
||||||
|
de dialogue", gérée AUTOMATIQUEMENT par le moteur (affiche la
|
||||||
|
réplique en cours de la quête déclenchée par une règle de
|
||||||
|
collision "quete", voir static/js/play/dialogue-box-controller.js) —
|
||||||
|
l'auteur ne fait que la poser où il veut et la styliser (panneau
|
||||||
|
de propriétés de droite, une fois sélectionnée). -->
|
||||||
|
<details class="propGroup">
|
||||||
|
<summary>🖥️ Interface</summary>
|
||||||
|
<div class="propGroupBody">
|
||||||
|
<button type="button" class="button is-small is-fullwidth" onclick="addSceneObject('dialogue_box')">💬 Boîte de dialogue</button>
|
||||||
|
<p class="help">Affiche automatiquement le dialogue de la quête en cours (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posée.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,7 +124,7 @@
|
|||||||
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
||||||
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
{% if o.id == selected_id and o.kind != 'fond' %}
|
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box') %}
|
||||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
||||||
aperçu visuel + réglable directement au glisser — seulement pour
|
aperçu visuel + réglable directement au glisser — seulement pour
|
||||||
l'objet SÉLECTIONNÉ (celui dont le panneau "🧱 Collision" est ouvert),
|
l'objet SÉLECTIONNÉ (celui dont le panneau "🧱 Collision" est ouvert),
|
||||||
@@ -245,12 +262,28 @@
|
|||||||
<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
<h2 class="subtitle is-6" style="margin:0;">{{ '🏃' if obj.kind == 'personnage' else '🖼️' }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else 'Décor') }}</h2>
|
<h2 class="subtitle is-6" style="margin:0;">{{ '🏃' if obj.kind == 'personnage' else ('💬' if obj.kind == 'dialogue_box' else '🖼️') }} {{ obj.name or ('Personnage' if obj.kind == 'personnage' else ('Boîte de dialogue' if obj.kind == 'dialogue_box' else 'Décor')) }}</h2>
|
||||||
<form method="post" action="{{ url_for('scene_object_delete', slug=game.slug, object_id=obj.id) }}" onsubmit="return confirm('Supprimer cet objet ?');">
|
<form method="post" action="{{ url_for('scene_object_delete', slug=game.slug, object_id=obj.id) }}" onsubmit="return confirm('Supprimer cet objet ?');">
|
||||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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). -->
|
||||||
|
<details class="propGroup" open>
|
||||||
|
<summary>ℹ️ Informations</summary>
|
||||||
|
<div class="propGroupBody">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Nom du personnage</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>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<details class="propGroup" open>
|
<details class="propGroup" open>
|
||||||
<summary>Position & taille (pixels)</summary>
|
<summary>Position & taille (pixels)</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
@@ -279,7 +312,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if obj.kind != 'fond' %}
|
{% if obj.kind == 'dialogue_box' %}
|
||||||
|
<!-- "🎨 Style" (voir screens/rendering/dialogue_box_style.py) : le
|
||||||
|
widget "💬 Boîte de dialogue" n'a ni collision ni animation,
|
||||||
|
juste une apparence — police, couleur du texte, couleur de
|
||||||
|
chaque zone (header/body/footer). -->
|
||||||
|
<details class="propGroup" open>
|
||||||
|
<summary>🎨 Style</summary>
|
||||||
|
<div class="propGroupBody" data-element-id="{{ obj.id }}">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Police</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input is-small" type="text" id="dbox-font-family" value="{{ dialogue_box_style.font_family }}" placeholder="ex. Georgia, serif" onchange="saveDialogueBoxStyle({{ obj.id }})">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns is-multiline posGrid">
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Taille (px)</label>
|
||||||
|
<div class="control"><input class="input is-small" type="number" id="dbox-font-size" min="8" max="48" value="{{ dialogue_box_style.font_size }}" onchange="saveDialogueBoxStyle({{ obj.id }})"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Épaisseur</label>
|
||||||
|
<div class="control">
|
||||||
|
<select class="input is-small" id="dbox-font-weight" onchange="saveDialogueBoxStyle({{ obj.id }})">
|
||||||
|
{% for w in dialogue_box_font_weight_choices %}
|
||||||
|
<option value="{{ w }}" {{ 'selected' if dialogue_box_style.font_weight == w else '' }}>{{ 'Gras' if w == 'bold' else 'Normal' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns is-multiline posGrid">
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Texte</label>
|
||||||
|
<div class="control"><input class="input is-small" type="color" id="dbox-text-color" value="{{ dialogue_box_style.text_color }}" onchange="saveDialogueBoxStyle({{ obj.id }})"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Header</label>
|
||||||
|
<div class="control"><input class="input is-small" type="color" id="dbox-header-bg" value="{{ dialogue_box_style.header_bg }}" onchange="saveDialogueBoxStyle({{ obj.id }})"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Corps</label>
|
||||||
|
<div class="control"><input class="input is-small" type="color" id="dbox-body-bg" value="{{ dialogue_box_style.body_bg }}" onchange="saveDialogueBoxStyle({{ obj.id }})"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="field"><label class="label is-small">Pied</label>
|
||||||
|
<div class="control"><input class="input is-small" type="color" id="dbox-footer-bg" value="{{ dialogue_box_style.footer_bg }}" onchange="saveDialogueBoxStyle({{ obj.id }})"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if obj.kind not in ('fond', 'dialogue_box') %}
|
||||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
|
||||||
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
|
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
|
||||||
sur tout objet (= sa boîte visuelle par défaut, tant que rien
|
sur tout objet (= sa boîte visuelle par défaut, tant que rien
|
||||||
@@ -464,6 +555,9 @@
|
|||||||
// avant collision-rules-editor.js : l'action "quete" de l'assistant
|
// avant collision-rules-editor.js : l'action "quete" de l'assistant
|
||||||
// "+ Action" ouvre directement cet éditeur (renderQuestPickerStep).
|
// "+ Action" ouvre directement cet éditeur (renderQuestPickerStep).
|
||||||
var QUESTS_JSON = {{ quests_json|safe }};
|
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 }};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/screen_edit/tree-panels.js') }}"></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>
|
<script src="{{ url_for('static', filename='js/screen_edit/panel-init.js') }}"></script>
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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():
|
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 = {
|
raw = {
|
||||||
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}, {"speaker": "joueur", "text": " "}],
|
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}],
|
||||||
"en_cours": [{"speaker": "sorcier", "text": "invalide"}, "pas un objet"],
|
"en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"],
|
||||||
"terminee": "pas une liste",
|
"terminee": "pas une liste",
|
||||||
"statut_inconnu": [{"speaker": "pnj", "text": "ignoré"}],
|
"statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}],
|
||||||
}
|
}
|
||||||
cleaned = db.sanitize_quest_dialogues(raw)
|
cleaned = db.sanitize_quest_dialogues(raw)
|
||||||
assert cleaned == {
|
assert cleaned == {
|
||||||
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}],
|
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}],
|
||||||
"en_cours": [],
|
"en_cours": [],
|
||||||
"terminee": [],
|
"terminee": [],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user