diff --git a/db/__init__.py b/db/__init__.py index 9d49d31f..35b2eb84 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -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", ] diff --git a/db/quests/constants.py b/db/quests/constants.py index ca54a481..d01ef3fe 100644 --- a/db/quests/constants.py +++ b/db/quests/constants.py @@ -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"] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py index dfaef61d..9f1aa16b 100644 --- a/db/quests/sanitize_quest_dialogues.py +++ b/db/quests/sanitize_quest_dialogues.py @@ -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: '', 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 diff --git a/routes/scenes/__init__.py b/routes/scenes/__init__.py index 8531ff69..f721b74a 100644 --- a/routes/scenes/__init__.py +++ b/routes/scenes/__init__.py @@ -7,4 +7,6 @@ from . import ( scene_object_role, scene_object_collision, scene_object_collision_rules, + scene_object_name, + scene_object_dialogue_box_style, ) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index 75d1fa0d..c05f004c 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -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: - collision_settings = screens.resolve_collision_settings(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)), ) diff --git a/routes/scenes/scene_object_dialogue_box_style.py b/routes/scenes/scene_object_dialogue_box_style.py new file mode 100644 index 00000000..41000402 --- /dev/null +++ b/routes/scenes/scene_object_dialogue_box_style.py @@ -0,0 +1,14 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//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}) diff --git a/routes/scenes/scene_object_name.py b/routes/scenes/scene_object_name.py new file mode 100644 index 00000000..0442af05 --- /dev/null +++ b/routes/scenes/scene_object_name.py @@ -0,0 +1,12 @@ +from flask import request, jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//scene-objects//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}) diff --git a/screens/__init__.py b/screens/__init__.py index 169c69c6..e98a027e 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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 diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 14930655..534fa6a0 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -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, } diff --git a/screens/rendering/dialogue_box_style.py b/screens/rendering/dialogue_box_style.py new file mode 100644 index 00000000..40aa6059 --- /dev/null +++ b/screens/rendering/dialogue_box_style.py @@ -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) diff --git a/screens/rendering/personnage_names.py b/screens/rendering/personnage_names.py new file mode 100644 index 00000000..ed88fb01 --- /dev/null +++ b/screens/rendering/personnage_names.py @@ -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) diff --git a/screens/scenes/add_scene_object.py b/screens/scenes/add_scene_object.py index 335a4ab9..abf56f16 100644 --- a/screens/scenes/add_scene_object.py +++ b/screens/scenes/add_scene_object.py @@ -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 diff --git a/screens/scenes/render_scene_object.py b/screens/scenes/render_scene_object.py index 1784cedc..a895d685 100644 --- a/screens/scenes/render_scene_object.py +++ b/screens/scenes/render_scene_object.py @@ -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
à 3 zones, pas un — 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'
' + f'
Nom du personnage
' + f'
Le texte du dialogue s\'affiche ici.
' + f'
' + f'' + f'
' + ) if obj["kind"] == "personnage": src = resolve_personnage_idle_frame(obj) src_attr = f' src="{html_lib.escape(src)}"' if src else "" diff --git a/screens/scenes/set_scene_object_dialogue_box_style.py b/screens/scenes/set_scene_object_dialogue_box_style.py new file mode 100644 index 00000000..69e8f59d --- /dev/null +++ b/screens/scenes/set_scene_object_dialogue_box_style.py @@ -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 diff --git a/screens/scenes/set_scene_object_name.py b/screens/scenes/set_scene_object_name.py new file mode 100644 index 00000000..9878c849 --- /dev/null +++ b/screens/scenes/set_scene_object_name.py @@ -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() diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js index 4ec3f1a2..b3d64dc1 100644 --- a/static/js/play/__tests__/collision-rules-controller.test.js +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -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', () => { diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js new file mode 100644 index 00000000..ef494c94 --- /dev/null +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -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; } +}); diff --git a/static/js/play/collision-rules-controller.js b/static/js/play/collision-rules-controller.js index 277b4b23..c5c1c1f8 100644 --- a/static/js/play/collision-rules-controller.js +++ b/static/js/play/collision-rules-controller.js @@ -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; } diff --git a/static/js/play/dialogue-box-controller.js b/static/js/play/dialogue-box-controller.js new file mode 100644 index 00000000..23e49fb2 --- /dev/null +++ b/static/js/play/dialogue-box-controller.js @@ -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()"). +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, + }; +} diff --git a/static/js/play/personnage-controller.js b/static/js/play/personnage-controller.js index 33e2746a..d4112e38 100644 --- a/static/js/play/personnage-controller.js +++ b/static/js/play/personnage-controller.js @@ -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); }); } diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js index 452def22..95d3d39e 100644 --- a/static/js/quests/quest-editor.js +++ b/static/js/quests/quest-editor.js @@ -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) { '
'; } +// Options du ' + speakerSelect + '' + '' + @@ -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); } diff --git a/static/js/scenes/scene-editor.js b/static/js/scenes/scene-editor.js index beea419c..0ea3c00b 100644 --- a/static/js/scenes/scene-editor.js +++ b/static/js/scenes/scene-editor.js @@ -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; diff --git a/static/style.css b/static/style.css index 1c7c6838..e5a75d14 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/templates/play.html b/templates/play.html index 3c1875ee..63c58335 100644 --- a/templates/play.html +++ b/templates/play.html @@ -84,7 +84,13 @@ uniquement si un objet kind="fond" plus grand que la scène est posé (caméra qui suit le personnage). -->
- {% 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 %} +
+ +
+ {% for obj in s.elements if obj.kind == 'dialogue_box' %}{{ obj.rendered_html|safe }}{% endfor %}
{% else %} @@ -155,6 +161,7 @@ + diff --git a/templates/scene_edit.html b/templates/scene_edit.html index 575aef86..d97be914 100644 --- a/templates/scene_edit.html +++ b/templates/scene_edit.html @@ -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
, pas un ). */ + .canvasElementInner > .dialogueBoxWidget{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; } {% endblock %} {% block content %} @@ -87,6 +90,20 @@
{% endif %} + +
+ 🖥️ Interface +
+ +

Affiche automatiquement le dialogue de la quête en cours (voir l'onglet "🗺️ Quêtes") — position et style réglables une fois posée.

+
+
@@ -107,7 +124,7 @@ onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
{{ o.rendered_html|safe }}
- {% if o.id == selected_id and o.kind != 'fond' %} + {% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box') %} +
+ ℹ️ Informations +
+
+ +
+
+
+
+ {% endif %} +
Position & taille (pixels)
@@ -279,7 +312,65 @@
- {% if obj.kind != 'fond' %} + {% if obj.kind == 'dialogue_box' %} + +
+ 🎨 Style +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %} + + {% if obj.kind not in ('fond', 'dialogue_box') %}