Quêtes/collision : personnages nommés, dialogues qui parlent, boîte de dialogue en jeu
Build and deploy / test-python (push) Successful in 11m32s
Build and deploy / test-js (push) Successful in 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- "ℹ️ Informations" (propriétés d'un personnage) : nom éditable, réutilisé
  comme "qui parle" dans l'éditeur de dialogue de quête (menu déroulant,
  plus "Joueur" toujours disponible) — remplace l'ancien choix binaire
  joueur/pnj. db.sanitize_quest_dialogues accepte maintenant un nom libre.

- Nouveau menu "🖥️ Interface" (palette d'objets) avec le premier widget :
  "💬 Boîte de dialogue" (kind="dialogue_box", screens/rendering/
  dialogue_box_style.py) — position/taille comme tout objet de scène,
  panneau "🎨 Style" dédié (police, taille, épaisseur, couleur du texte,
  couleur header/body/footer). Rendu en <div> à 3 zones, jamais soumis à
  la collision ni à l'éditeur de collision (exclu partout : obstacles,
  liste des règles, payload). Rendu côté jeu dans .sceneUI, une couche
  FIXE au viewport (jamais .sceneWorld, qui défile avec la caméra).

- static/js/play/dialogue-box-controller.js : fait le lien moteur entre
  l'action "quete" de l'éditeur de collision et ce widget — affiche la
  réplique en cours du dialogue correspondant au STATUT ACTUEL de la
  quête (gameData.quests, désormais exposé game-wide par
  full_game_payload.py), avance au clic sur "Suivant" (header = qui
  parle, body = texte, footer = bouton), se masque à la dernière réplique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-03 11:20:58 +02:00
co-authored by Claude Sonnet 5
parent 390ee831ca
commit 2347a70c45
27 changed files with 774 additions and 41 deletions
+2 -2
View File
@@ -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 .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.get_quest import get_quest
@@ -106,7 +106,7 @@ __all__ = [
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"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",
"set_quest_dialogues", "sanitize_quest_dialogues",
]
-1
View File
@@ -2,4 +2,3 @@ QUEST_STATUS_CHOICES = ["nouvelle", "en_cours", "terminee"]
QUEST_STATUS_LABELS = {"nouvelle": "Nouvelle", "en_cours": "En cours", "terminee": "Terminée"}
QUEST_RESULT_CHOICES = ["reussi", "echoue"]
QUEST_RESULT_LABELS = {"reussi": "Réussi", "echoue": "Échoué"}
QUEST_SPEAKER_CHOICES = ["joueur", "pnj"]
+14 -7
View File
@@ -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_SPEAKER_LENGTH = 60
def sanitize_quest_dialogues(raw_dialogues):
"""Une réplique = {speaker: 'joueur'|'pnj', text: '...'} — une colonne
par statut de quête (voir QUEST_STATUS_CHOICES), la même forme que
resolve_collision_rules.sanitize_collision_rules : rejette tout
élément invalide plutôt que de lever, ne garde jamais un texte vide."""
"""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
"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}
if not isinstance(raw_dialogues, dict):
return result
@@ -21,10 +28,10 @@ def sanitize_quest_dialogues(raw_dialogues):
continue
speaker = raw_line.get("speaker")
text = raw_line.get("text")
if speaker not in QUEST_SPEAKER_CHOICES:
if not isinstance(speaker, str) or not speaker.strip():
continue
if not isinstance(text, str) or not text.strip():
continue
cleaned.append({"speaker": speaker, "text": text.strip()})
cleaned.append({"speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()})
result[status] = cleaned
return result
+2
View File
@@ -7,4 +7,6 @@ from . import (
scene_object_role,
scene_object_collision,
scene_object_collision_rules,
scene_object_name,
scene_object_dialogue_box_style,
)
+11 -1
View File
@@ -26,13 +26,17 @@ def render_scene_edit(slug, screen_id, game):
personnage_commands = None
personnage_role = 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
if selected_obj:
if selected_obj["kind"] != "dialogue_box":
collision_settings = screens.resolve_collision_settings(selected_obj)
if selected_obj["kind"] == "personnage":
personnage_data = screens.resolve_personnage_data(selected_obj)
personnage_commands = screens.resolve_personnage_commands(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"}
object_labels = {
@@ -52,7 +56,7 @@ def render_scene_edit(slug, screen_id, game):
# SÉLECTIONNÉ, mais nécessaire ici pour TOUS les personnages).
collision_rule_objects = []
for o in objects:
if o["kind"] == "fond":
if o["kind"] in ("fond", "dialogue_box"):
continue
role = screens.resolve_personnage_role(o) if o["kind"] == "personnage" else None
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_choices=screens.PERSONNAGE_ROLES, personnage_role_labels=screens.PERSONNAGE_ROLE_LABELS,
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_json=json.dumps(sprite_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"
# (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)),
)
@@ -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})
+12
View File
@@ -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})
+4
View File
@@ -88,6 +88,8 @@ 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.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.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_collision import set_scene_object_collision
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.flow_palette import TRIGGER_EVENTS_2D, ACTION_TYPE_LABELS_2D, OBJECT_ACTION_PROPERTIES
from .elements.element_label import element_label
+17
View File
@@ -12,6 +12,7 @@ from ..rendering.personnage_commands import resolve_personnage_commands
from ..rendering.personnage_role import resolve_personnage_role
from ..rendering.collision_settings import resolve_collision_settings
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.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":
o["personnage_commandes"] = resolve_personnage_commands(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::
# elementsOverlap) : posée sur TOUT objet de scène, pas
# 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.
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 {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"variables": variables, "personnage_animations": personnage_animations,
"definition_names": definition_names, "element_type_templates": element_type_templates,
"quests": quests,
}
+50
View File
@@ -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)
+19
View File
@@ -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)
+7 -1
View File
@@ -5,7 +5,11 @@ import db
from .ensure_scene_schema import ensure_scene_schema
_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):
@@ -40,6 +44,8 @@ def add_scene_object(slug, scene_id, kind="personnage", forge_character=None, ba
if background:
attributes["src"] = background["url"]
width, height = background["width"], background["height"]
elif kind == "dialogue_box":
width, height = _DIALOGUE_BOX_DEFAULT_SIZE
conn = db.connect(slug)
if kind == "fond":
z_index = -1000
+26
View File
@@ -1,6 +1,7 @@
import html as html_lib
from ..rendering.personnage_data import resolve_personnage_idle_frame
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
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
# `.canvasElementInner > img` dans scene_edit.html) mais DÉFORMÉE,
# 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":
src = resolve_personnage_idle_frame(obj)
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
+18
View File
@@ -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]);
});
test('forgeRunCollisionRuleAction — "quete" mémorise l\'id dans window.forgeQuestLog (pas d\'éditeur de quête pour le moment)', () => {
window.forgeQuestLog = [];
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 'q-1' }, fakeEl(), { id: 1 });
assert.deepEqual(window.forgeQuestLog, ['q-1']);
test('forgeRunCollisionRuleAction — "quete" délègue à forgeShowDialogueBox (voir dialogue-box-controller.js)', () => {
const calls = [];
const previousShow = global.forgeShowDialogueBox;
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', () => {
@@ -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; }
});
+8 -4
View File
@@ -80,10 +80,12 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
return;
}
if (action.type === 'quete') {
// Éditeur de quête pas encore construit (voir plan) — mémorise
// l'intention pour un futur système de quêtes plutôt que de la perdre.
window.forgeQuestLog = window.forgeQuestLog || [];
window.forgeQuestLog.push(action.quete_id);
// Affiche la réplique en cours du dialogue de cette quête, selon son
// statut actuel, dans le widget "💬 Boîte de dialogue" posé sur
// l'écran (voir static/js/play/dialogue-box-controller.js) — sans
// 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;
}
}
@@ -185,4 +187,6 @@ if (typeof module !== 'undefined' && module.exports) {
const personnageMod = require('./personnage-controller.js');
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
const dialogueBoxMod = require('./dialogue-box-controller.js');
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
}
+72
View File
@@ -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,
};
}
+1 -1
View File
@@ -113,7 +113,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
// se déplace lui-même.
function forgeSolidObstacles(screenData, movingId) {
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);
});
}
+45 -10
View File
@@ -27,6 +27,20 @@ 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 : [];
// 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) {
return QUESTS.find(function (q) { return q.id === questId; });
}
@@ -178,13 +192,26 @@ function renderQuestFieldsModalBody(questId) {
'</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) {
var kind = questBubbleKind(line.speaker);
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>';
}).join('');
return (
'<div class="questBubbleRow questBubbleRow-' + line.speaker + '">' +
'<div class="questBubble questBubble-' + line.speaker + '">' +
'<div class="questBubbleSpeaker" onclick="questToggleSpeaker(\'' + status + '\', ' + index + ')" title="Changer qui parle">' +
(line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') +
'</div>' +
'<div class="questBubbleRow questBubbleRow-' + kind + '">' +
'<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, '&lt;') + '</textarea>' +
@@ -292,22 +319,30 @@ function questSaveDialogues(quest) {
.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) {
var quest = questById(_currentQuestModalId());
if (!quest) return;
quest.dialogues = quest.dialogues || {};
var lines = quest.dialogues[status] || (quest.dialogues[status] = []);
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'pnj';
lines.push({ speaker: lastSpeaker === 'pnj' ? 'joueur' : 'pnj', text: '' });
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
lines.push({ speaker: questNextDefaultSpeaker(lastSpeaker), text: '' });
renderQuestDialogueModalBody(quest.id);
questSaveDialogues(quest);
}
function questToggleSpeaker(status, index) {
function questSetBubbleSpeaker(status, index, speaker) {
var quest = questById(_currentQuestModalId());
if (!quest) return;
var line = quest.dialogues[status][index];
line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur';
quest.dialogues[status][index].speaker = speaker;
renderQuestDialogueModalBody(quest.id);
questSaveDialogues(quest);
}
+41
View File
@@ -361,6 +361,47 @@ function addPersonnageCommandExtraRow() {
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 () {
var body = document.getElementById('personnageCommandsBody');
if (!body) return;
+30 -1
View File
@@ -634,6 +634,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
comporte donc exactement comme avant. */
.playScreen.playScene{ overflow:hidden; }
.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
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-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{
resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px;
font-family:inherit; padding:0; min-height:1.4em;
+8 -1
View File
@@ -84,7 +84,13 @@
uniquement si un objet kind="fond" plus grand que la scène est posé
(caméra qui suit le personnage). -->
<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>
{% else %}
@@ -155,6 +161,7 @@
<script src="{{ asset_url('js/play/actions.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/dialogue-box-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/flow-engine.js') }}"></script>
+97 -3
View File
@@ -15,6 +15,9 @@
#canvas (son ancêtre positionné le plus proche), en double du
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; }
/* 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>
{% endblock %}
{% block content %}
@@ -87,6 +90,20 @@
</div>
</details>
{% 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>
@@ -107,7 +124,7 @@
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
<div class="canvasElementInner">{{ o.rendered_html|safe }}</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) :
aperçu visuel + réglable directement au glisser — seulement pour
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>
{% else %}
<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 ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</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). -->
<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>
<summary>Position &amp; taille (pixels)</summary>
<div class="propGroupBody">
@@ -279,7 +312,65 @@
</div>
</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,
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
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
// "+ 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 }};
</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>
+140
View File
@@ -0,0 +1,140 @@
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
screens/rendering/dialogue_box_style.py, screens/scenes/add_scene_object.py
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en
cours d'une quête déclenchée par une règle de collision "quete" (voir
static/js/play/dialogue-box-controller.js) — header/body/footer
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
l'éditeur de dialogue de quête (screens/rendering/personnage_names.py)."""
import db
import screens
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_dialogue_box"):
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def test_add_dialogue_box_has_sensible_default_size(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
assert obj["kind"] == "dialogue_box"
assert obj["width"] == 320
assert obj["height"] == 150
def test_dialogue_box_default_style(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
style = screens.resolve_dialogue_box_style(obj)
assert style == screens.DEFAULT_DIALOGUE_BOX_STYLE
def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
html = screens.render_scene_object(obj)
assert "dialogueBoxHeader" in html
assert "dialogueBoxBody" in html
assert "dialogueBoxFooter" in html
assert f'data-object-id="{obj_id}"' in html
def test_set_scene_object_dialogue_box_style_persists_and_sanitizes(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
style = screens.set_scene_object_dialogue_box_style(slug, obj_id, {
"font_family": "Georgia", "font_size": 999, "font_weight": "bold",
"text_color": "#ff0000", "header_bg": "#000000", "unknown_key": "ignored",
})
assert style["font_family"] == "Georgia"
assert style["font_size"] == 48 # bornée au maximum autorisé
assert style["font_weight"] == "bold"
assert style["text_color"] == "#ff0000"
assert style["header_bg"] == "#000000"
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_dialogue_box_style(obj) == style
def test_dialogue_box_route_persists_style(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/dialogue-box-style",
data='{"style": {"header_bg": "#123456"}}', content_type="application/json",
)
assert resp.get_json()["style"]["header_bg"] == "#123456"
def test_dialogue_box_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/dialogue-box-style", data='{"style": {}}', content_type="application/json")
assert resp.status_code == 404
def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="dialogue_box")
payload = screens.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
box = next(o for o in scene["elements"] if o["kind"] == "dialogue_box")
assert "collision_rules" not in box
assert "collision" not in box
assert box["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
def test_full_game_payload_exposes_quests_game_wide(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
db.create_quest(slug, "Trouver la clé")
payload = screens.full_game_payload(slug)
assert [q["title"] for q in payload["quests"]] == ["Trouver la clé"]
# ---------- Nom d'un personnage ("ℹ️ Informations") ----------
def test_scene_object_name_is_none_by_default(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
obj = screens.get_scene_object(slug, obj_id)
assert obj["name"] is None
def test_set_scene_object_name_persists_and_trims(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_name(slug, obj_id, " Le Sorcier ")
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
def test_set_scene_object_name_blank_clears_it(tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_name(slug, obj_id, "Le Sorcier")
screens.set_scene_object_name(slug, obj_id, " ")
assert screens.get_scene_object(slug, obj_id)["name"] is None
def test_scene_object_name_route_persists(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
assert resp.status_code == 200
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
def test_list_personnage_names_is_alphabetical_and_deduped_across_scenes(tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(db.create_game("pytest_personnage_names"))
scene_a = screens.create_screen(slug, "Scène A", kind="jeu_2d")
scene_b = screens.create_screen(slug, "Scène B", kind="jeu_2d")
zed_id = screens.add_scene_object(slug, scene_a, kind="personnage")
screens.set_scene_object_name(slug, zed_id, "Zed le forgeron")
anna_id = screens.add_scene_object(slug, scene_b, kind="personnage")
screens.set_scene_object_name(slug, anna_id, "Anna")
unnamed_id = screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré
decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # pas un personnage, ignoré
screens.set_scene_object_name(slug, decor_id, "Un décor nommé")
assert screens.list_personnage_names(slug) == ["Anna", "Zed le forgeron"]
+8 -4
View File
@@ -74,15 +74,19 @@ def test_delete_quest_removes_it(tmp_game_slug_cleanup):
def test_sanitize_quest_dialogues_keeps_only_valid_lines_per_status():
"""speaker est un nom de PERSONNAGE libre (voir "ℹ️ Informations",
screens/rendering/personnage_names.py) — "Le Sorcier" est donc un nom
valide comme n'importe quel autre, seul un speaker/texte VIDE ou un
élément mal formé est rejeté."""
raw = {
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}, {"speaker": "joueur", "text": " "}],
"en_cours": [{"speaker": "sorcier", "text": "invalide"}, "pas un objet"],
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}, {"speaker": "Joueur", "text": " "}],
"en_cours": [{"speaker": " ", "text": "sans nom"}, {"speaker": 42, "text": "nom non textuel"}, "pas un objet"],
"terminee": "pas une liste",
"statut_inconnu": [{"speaker": "pnj", "text": "ignoré"}],
"statut_inconnu": [{"speaker": "Le Sorcier", "text": "ignoré"}],
}
cleaned = db.sanitize_quest_dialogues(raw)
assert cleaned == {
"nouvelle": [{"speaker": "pnj", "text": "Bonjour !"}],
"nouvelle": [{"speaker": "Le Sorcier", "text": "Bonjour !"}],
"en_cours": [],
"terminee": [],
}