diff --git a/db/__init__.py b/db/__init__.py index 8cab4a08..9d49d31f 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -78,6 +78,17 @@ from .custom_events.get_custom_event import get_custom_event 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, +) +from .quests.list_quests import list_quests +from .quests.get_quest import get_quest +from .quests.create_quest import create_quest +from .quests.update_quest import update_quest +from .quests.delete_quest import delete_quest +from .quests.set_quest_dialogues import set_quest_dialogues +from .quests.sanitize_quest_dialogues import sanitize_quest_dialogues + __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", @@ -95,4 +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", + "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 new file mode 100644 index 00000000..ca54a481 --- /dev/null +++ b/db/quests/constants.py @@ -0,0 +1,5 @@ +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/create_quest.py b/db/quests/create_quest.py new file mode 100644 index 00000000..83522dc3 --- /dev/null +++ b/db/quests/create_quest.py @@ -0,0 +1,18 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest + + +def create_quest(slug, title="Nouvelle quête"): + """Titre par défaut plutôt qu'un refus — créée d'un clic depuis la + liste (voir templates/quests.html) ou depuis l'assistant "+ Action" + de l'éditeur de collision (voir collision-rules-editor.js), avant + même que l'auteur ait choisi un titre.""" + ensure_quests_schema(slug) + title = (title or "").strip() or "Nouvelle quête" + conn = connect(slug) + conn.execute("INSERT INTO _quests (title) VALUES (?)", (title,)) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return get_quest(slug, new_id) diff --git a/db/quests/delete_quest.py b/db/quests/delete_quest.py new file mode 100644 index 00000000..80e19b75 --- /dev/null +++ b/db/quests/delete_quest.py @@ -0,0 +1,10 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema + + +def delete_quest(slug, quest_id): + ensure_quests_schema(slug) + conn = connect(slug) + conn.execute("DELETE FROM _quests WHERE id = ?", (quest_id,)) + conn.commit() + conn.close() diff --git a/db/quests/ensure_quests_schema.py b/db/quests/ensure_quests_schema.py new file mode 100644 index 00000000..8013cd81 --- /dev/null +++ b/db/quests/ensure_quests_schema.py @@ -0,0 +1,29 @@ +from ..connection import connect + + +def ensure_quests_schema(slug): + """Migration légère (même principe que ensure_custom_events_schema.py) : + crée _quests si absente. Une quête vit pour TOUT le jeu (pas par écran), + référencée par id technique depuis l'action "quete" d'une règle de + collision (voir screens/rendering/collision_rules.py, quete_id). Les + dialogues (voir static/js/quests/quest-editor.js — 3 colonnes + nouvelle/en_cours/terminee) sont stockés en JSON plutôt qu'une table à + part : jamais interrogés/filtrés côté SQL, toujours lus/réécrits en + bloc pour UNE quête à la fois (même convention que _scene_objects.attributes).""" + conn = connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _quests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + objectif TEXT DEFAULT '', + recompense_score INTEGER DEFAULT 0, + status TEXT DEFAULT 'nouvelle', + resultat TEXT, + dialogues_json TEXT DEFAULT '{}', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/db/quests/get_quest.py b/db/quests/get_quest.py new file mode 100644 index 00000000..634d6e70 --- /dev/null +++ b/db/quests/get_quest.py @@ -0,0 +1,11 @@ +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .list_quests import _row_to_quest + + +def get_quest(slug, quest_id): + ensure_quests_schema(slug) + conn = connect(slug) + row = conn.execute("SELECT * FROM _quests WHERE id = ?", (quest_id,)).fetchone() + conn.close() + return _row_to_quest(row) if row else None diff --git a/db/quests/list_quests.py b/db/quests/list_quests.py new file mode 100644 index 00000000..734a160a --- /dev/null +++ b/db/quests/list_quests.py @@ -0,0 +1,22 @@ +import json + +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema + + +def _row_to_quest(row): + quest = dict(row) + try: + quest["dialogues"] = json.loads(quest.pop("dialogues_json") or "{}") + except (TypeError, ValueError): + quest["dialogues"] = {} + quest.pop("dialogues_json", None) + return quest + + +def list_quests(slug): + ensure_quests_schema(slug) + conn = connect(slug) + rows = conn.execute("SELECT * FROM _quests ORDER BY created_at DESC, id DESC").fetchall() + conn.close() + return [_row_to_quest(r) for r in rows] diff --git a/db/quests/sanitize_quest_dialogues.py b/db/quests/sanitize_quest_dialogues.py new file mode 100644 index 00000000..dfaef61d --- /dev/null +++ b/db/quests/sanitize_quest_dialogues.py @@ -0,0 +1,30 @@ +from .constants import QUEST_STATUS_CHOICES, QUEST_SPEAKER_CHOICES + +_MAX_LINES_PER_COLUMN = 200 + + +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.""" + result = {status: [] for status in QUEST_STATUS_CHOICES} + if not isinstance(raw_dialogues, dict): + return result + for status in QUEST_STATUS_CHOICES: + raw_lines = raw_dialogues.get(status) + if not isinstance(raw_lines, list): + continue + cleaned = [] + for raw_line in raw_lines[:_MAX_LINES_PER_COLUMN]: + if not isinstance(raw_line, dict): + continue + speaker = raw_line.get("speaker") + text = raw_line.get("text") + if speaker not in QUEST_SPEAKER_CHOICES: + continue + if not isinstance(text, str) or not text.strip(): + continue + cleaned.append({"speaker": speaker, "text": text.strip()}) + result[status] = cleaned + return result diff --git a/db/quests/set_quest_dialogues.py b/db/quests/set_quest_dialogues.py new file mode 100644 index 00000000..62a4ed65 --- /dev/null +++ b/db/quests/set_quest_dialogues.py @@ -0,0 +1,21 @@ +import json + +from ..connection import connect +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest +from .sanitize_quest_dialogues import sanitize_quest_dialogues + + +def set_quest_dialogues(slug, quest_id, raw_dialogues): + """Toujours les 3 colonnes ENTIÈRES (nouvelle/en_cours/terminee) en un + bloc — même convention "replace-all" que set_scene_object_collision_rules.py, + jamais une réplique isolée.""" + ensure_quests_schema(slug) + if get_quest(slug, quest_id) is None: + return None + dialogues = sanitize_quest_dialogues(raw_dialogues) + conn = connect(slug) + conn.execute("UPDATE _quests SET dialogues_json = ? WHERE id = ?", (json.dumps(dialogues), quest_id)) + conn.commit() + conn.close() + return dialogues diff --git a/db/quests/update_quest.py b/db/quests/update_quest.py new file mode 100644 index 00000000..f8fec251 --- /dev/null +++ b/db/quests/update_quest.py @@ -0,0 +1,42 @@ +from ..connection import connect +from .constants import QUEST_STATUS_CHOICES, QUEST_RESULT_CHOICES +from .ensure_quests_schema import ensure_quests_schema +from .get_quest import get_quest + + +def update_quest(slug, quest_id, fields): + """Met à jour uniquement les clés reconnues et valides de `fields` — + ignore tout le reste plutôt que de lever, même esprit que + sanitize_collision_rules (jamais confiance dans ce qu'envoie le + client). title vide => ignoré (garde l'ancien plutôt qu'un titre vide).""" + ensure_quests_schema(slug) + if get_quest(slug, quest_id) is None: + return None + updates = {} + if "title" in fields: + title = (fields["title"] or "").strip() + if title: + updates["title"] = title + if "objectif" in fields: + updates["objectif"] = (fields["objectif"] or "").strip() + if "recompense_score" in fields: + try: + updates["recompense_score"] = int(fields["recompense_score"]) + except (TypeError, ValueError): + pass + if "status" in fields and fields["status"] in QUEST_STATUS_CHOICES: + updates["status"] = fields["status"] + if "resultat" in fields: + resultat = fields["resultat"] + if resultat in QUEST_RESULT_CHOICES: + updates["resultat"] = resultat + elif resultat is None: + updates["resultat"] = None + if not updates: + return get_quest(slug, quest_id) + conn = connect(slug) + set_clause = ", ".join(f"{key} = ?" for key in updates) + conn.execute(f"UPDATE _quests SET {set_clause} WHERE id = ?", (*updates.values(), quest_id)) + conn.commit() + conn.close() + return get_quest(slug, quest_id) diff --git a/routes/__init__.py b/routes/__init__.py index 13e82f50..0a52327b 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, quests diff --git a/routes/quests/__init__.py b/routes/quests/__init__.py new file mode 100644 index 00000000..0bbd7d75 --- /dev/null +++ b/routes/quests/__init__.py @@ -0,0 +1 @@ +from . import quests_view, quests_list_json, quest_new, quest_update, quest_delete, quest_dialogues diff --git a/routes/quests/quest_delete.py b/routes/quests/quest_delete.py new file mode 100644 index 00000000..c726589d --- /dev/null +++ b/routes/quests/quest_delete.py @@ -0,0 +1,11 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//quests//delete", methods=["POST"]) +def quest_delete(slug, quest_id): + db.delete_quest(slug, quest_id) + return jsonify({"ok": True}) diff --git a/routes/quests/quest_dialogues.py b/routes/quests/quest_dialogues.py new file mode 100644 index 00000000..2f318de1 --- /dev/null +++ b/routes/quests/quest_dialogues.py @@ -0,0 +1,17 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests//dialogues", methods=["POST"]) +def quest_dialogues(slug, quest_id): + """Toujours les 3 colonnes ENTIÈRES (voir db.set_quest_dialogues) — + le client (renderQuestDialogueColumns, quest-editor.js) poste l'objet + complet à chaque modification plutôt qu'une réplique isolée.""" + body = request.get_json(silent=True) or {} + dialogues = db.set_quest_dialogues(slug, quest_id, body.get("dialogues")) + if dialogues is None: + return jsonify({"error": "quête introuvable"}), 404 + return jsonify({"dialogues": dialogues}) diff --git a/routes/quests/quest_new.py b/routes/quests/quest_new.py new file mode 100644 index 00000000..a7b33ebc --- /dev/null +++ b/routes/quests/quest_new.py @@ -0,0 +1,12 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests/new", methods=["POST"]) +def quest_new(slug): + body = request.get_json(silent=True) or {} + quest = db.create_quest(slug, body.get("title")) + return jsonify({"quest": quest}) diff --git a/routes/quests/quest_update.py b/routes/quests/quest_update.py new file mode 100644 index 00000000..2c922c82 --- /dev/null +++ b/routes/quests/quest_update.py @@ -0,0 +1,14 @@ +from flask import jsonify, request + +import db + +from core.flask_app import app + + +@app.route("/game//quests/", methods=["POST"]) +def quest_update(slug, quest_id): + body = request.get_json(silent=True) or {} + quest = db.update_quest(slug, quest_id, body) + if quest is None: + return jsonify({"error": "quête introuvable"}), 404 + return jsonify({"quest": quest}) diff --git a/routes/quests/quests_list_json.py b/routes/quests/quests_list_json.py new file mode 100644 index 00000000..94dbed50 --- /dev/null +++ b/routes/quests/quests_list_json.py @@ -0,0 +1,14 @@ +from flask import jsonify + +import db + +from core.flask_app import app + + +@app.route("/game//quests/list.json") +def quests_list_json(slug): + """Utilisé par l'assistant "+ Action" de l'éditeur de collision (voir + collision-rules-editor.js) pour lister les quêtes existantes sans + quitter la modale déjà ouverte — même contenu que quests_view, en JSON + seul plutôt qu'une page complète.""" + return jsonify({"quests": db.list_quests(slug)}) diff --git a/routes/quests/quests_view.py b/routes/quests/quests_view.py new file mode 100644 index 00000000..f9afc130 --- /dev/null +++ b/routes/quests/quests_view.py @@ -0,0 +1,20 @@ +import json + +from flask import render_template + +import db + +from core.flask_app import app + + +@app.route("/game//quests") +def quests_view(slug): + """Éditeur de quêtes (voir db/quests/) — page dédiée game-wide, dans le + même esprit que game_dashboard.html : une liste de cartes + un bouton + "+ Nouvelle quête" au-dessus, chaque carte ouvrant une modale avec + l'arbre de dialogue (static/js/quests/quest-editor.js — même module + JS que celui rendu depuis l'assistant "+ Action" de l'éditeur de + collision, voir collision-rules-editor.js).""" + game = db.game_meta(slug) + quests = db.list_quests(slug) + return render_template("quests.html", game=game, quests_json=json.dumps(quests)) diff --git a/routes/scenes/scene_edit_view.py b/routes/scenes/scene_edit_view.py index c8fb5c11..75d1fa0d 100644 --- a/routes/scenes/scene_edit_view.py +++ b/routes/scenes/scene_edit_view.py @@ -128,4 +128,10 @@ def render_scene_edit(slug, screen_id, game): collision_rule_objects_json=json.dumps(collision_rule_objects), collision_action_types=screens.ACTION_TYPES, collision_trigger_types=screens.TRIGGER_TYPES, collision_default_perimetre_px=screens.DEFAULT_PERIMETRE_PX, + # Éditeur de quêtes (voir db/quests/) : préchargées ici pour que + # collisionRuleActionSummary (collision-rules-editor.js) affiche + # tout de suite le TITRE de la quête référencée par une règle + # 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)), ) diff --git a/screens/rendering/collision_rules.py b/screens/rendering/collision_rules.py index 738e2ebc..330f60a2 100644 --- a/screens/rendering/collision_rules.py +++ b/screens/rendering/collision_rules.py @@ -11,9 +11,13 @@ import json # joueur entre dans un rayon de `perimetre_px` pixels autour de l'objet). # # Action (une seule par règle) : -# - "quete" : quete_id (référence libre — l'éditeur de quête lui-même -# reste à construire, cette action ne fait QUE mémoriser -# la référence pour l'instant). +# - "quete" : quete_id (id technique d'une quête — voir db/quests/, +# choisie/créée via l'assistant "+ Action" qui ouvre +# directement l'éditeur de quête, static/js/quests/ +# quest-editor.js) — une quête inexistante/supprimée +# depuis reste une référence orpheline plutôt qu'une +# contrainte SQL, comme le reste de ce moteur (voir +# evenement_id ci-dessous, même principe). # - "attaque" : joue la pose "attack" du personnage si disponible (voir # personnage-controller.js) — pas de système de combat # (dégâts/PV...) pour l'instant, effet visuel seul. @@ -45,10 +49,10 @@ def _sanitize_action(raw_action, depth=1): return None # un seul niveau d'imbrication autorisé action = {"type": action_type} if action_type == "quete": - quete_id = raw_action.get("quete_id") - if not (isinstance(quete_id, str) and quete_id.strip()): + try: + action["quete_id"] = int(raw_action.get("quete_id")) + except (TypeError, ValueError): return None - action["quete_id"] = quete_id.strip() elif action_type == "evenement": try: action["evenement_id"] = int(raw_action.get("evenement_id")) diff --git a/static/js/quests/quest-editor.js b/static/js/quests/quest-editor.js new file mode 100644 index 00000000..5b820af9 --- /dev/null +++ b/static/js/quests/quest-editor.js @@ -0,0 +1,301 @@ +// ---------- Éditeur de quêtes ---------- +// Module partagé entre templates/quests.html (page dédiée : liste de +// cartes + "+ Nouvelle quête") et static/js/scenes/collision-rules-editor.js +// (l'assistant "+ Action" de l'éditeur de collision ouvre CETTE MÊME +// modale de dialogue quand l'auteur choisit "Déclencher une quête", +// plutôt qu'un simple champ texte pour un id). La modale (arbre de +// dialogue) est créée dynamiquement (ensureQuestDialogueModal) plutôt que +// dupliquée dans deux templates. +// +// Une quête (voir db/quests/) : id, title, objectif, recompense_score, +// status (nouvelle/en_cours/terminee — statut COURANT, informatif), +// resultat (reussi/echoue), dialogues (une liste de répliques PAR +// STATUT — 3 colonnes dans l'éditeur : quel dialogue jouer selon l'état +// de la quête au moment où le joueur parle au PNJ). + +var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' }; +var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' }; +var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee']; + +// Quêtes connues côté client — préremplies par QUESTS_JSON (quests.html) +// si présent, sinon vide tant qu'on n'a pas encore fetch (contexte +// éditeur de collision, voir questPickerEnsureLoaded). +var QUESTS = (typeof QUESTS_JSON !== 'undefined') ? QUESTS_JSON : []; + +function questById(questId) { + return QUESTS.find(function (q) { return q.id === questId; }); +} + +function questCardHtml(quest, i) { + var resultatHtml = quest.resultat ? '' + QUEST_RESULT_LABELS[quest.resultat] + '' : ''; + return ( + '
' + + '
' + + '

' + (quest.title || '').replace(/' + + '' + QUEST_STATUS_LABELS[quest.status] + '' + + '

' + + '

' + (quest.objectif ? quest.objectif.replace(/Aucun objectif défini.') + '

' + + '
' + + '🏆 +' + (quest.recompense_score || 0) + '' + + resultatHtml + + '
' + + '
' + ); +} + +function renderQuestListPage() { + var container = document.getElementById('questsGrid'); + if (!container) return; + if (!QUESTS.length) { + container.innerHTML = '

Aucune quête pour l\'instant — crée-en une avec le bouton ci-dessus.

'; + return; + } + container.innerHTML = QUESTS.map(questCardHtml).join(''); +} + +function createQuestFromListPage() { + fetch('/game/' + GAME_SLUG + '/quests/new', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + QUESTS.unshift(json.quest); + renderQuestListPage(); + openQuestDialogueModal(json.quest.id); + }); +} + +// ---------- Modale "arbre de dialogue" ---------- +// Créée une seule fois (au premier appel), réutilisée ensuite — partagée +// entre la page dédiée et l'assistant "+ Action" de l'éditeur de +// collision, jamais dupliquée dans les templates. +var _questModalOnUse = null; + +function ensureQuestDialogueModal() { + var modal = document.getElementById('questDialogueModal'); + if (modal) return modal; + modal = document.createElement('div'); + modal.className = 'modal'; + modal.id = 'questDialogueModal'; + modal.innerHTML = + '' + + ''; + document.body.appendChild(modal); + return modal; +} + +// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...) +// pour finaliser la règle de collision avec cette quête, affiche alors un +// bouton "✓ Utiliser cette quête" épinglé en haut de la modale. +function openQuestDialogueModal(questId, onUse) { + _questModalOnUse = onUse || null; + var modal = ensureQuestDialogueModal(); + modal.classList.add('is-active'); + renderQuestDialogueModalBody(questId); +} + +function closeQuestDialogueModal() { + var modal = document.getElementById('questDialogueModal'); + if (modal) modal.classList.remove('is-active'); + _questModalOnUse = null; + _questModalCurrentId = null; +} + +function questUseThisQuest(questId) { + var quest = questById(questId); + if (_questModalOnUse && quest) _questModalOnUse(quest); + closeQuestDialogueModal(); +} + +function questFieldRowHtml(quest) { + return ( + '
' + + '" + + '" + + '" + + '' + + '' + + '
' + ); +} + +function questDialogueBubbleHtml(line, status, index) { + return ( + '
' + + '
' + + '
' + + (line.speaker === 'joueur' ? '🧑 Joueur' : '🗨️ PNJ') + + '
' + + '' + + '' + + '
' + + '
' + ); +} + +function questDialogueColumnHtml(quest, status) { + var lines = (quest.dialogues && quest.dialogues[status]) || []; + var bubblesHtml = lines.map(function (line, i) { return questDialogueBubbleHtml(line, status, i); }).join(''); + return ( + '
' + + '

' + QUEST_STATUS_LABELS[status] + '

' + + '
' + bubblesHtml + '
' + + '' + + '
' + ); +} + +var _questModalCurrentId = null; + +function renderQuestDialogueModalBody(questId) { + var quest = questById(questId); + var body = document.getElementById('questDialogueModalBody'); + if (!quest || !body) return; + _questModalCurrentId = questId; + var useBtn = _questModalOnUse + ? '' + : ''; + body.innerHTML = + '
' + + '

🗺️ ' + (quest.title || '').replace(/' + + '
' + useBtn + + '' + + '
' + + '

' + + questFieldRowHtml(quest) + + '

Dialogue joué selon l\'état de la quête au moment où le joueur parle au PNJ :

' + + '
' + QUEST_STATUS_ORDER.map(function (s) { return questDialogueColumnHtml(quest, s); }).join('') + '
'; +} + +function questUpdateField(questId, field, value) { + var quest = questById(questId); + if (!quest) return; + var body = {}; + body[field] = field === 'recompense_score' ? Number(value) : (value || null); + fetch('/game/' + GAME_SLUG + '/quests/' + questId, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + if (!json.quest) return; + Object.assign(quest, json.quest); + renderQuestListPage(); + if (document.getElementById('questDialogueModal') && document.getElementById('questDialogueModal').classList.contains('is-active')) { + renderQuestDialogueModalBody(questId); + } + }); +} + +// Dialogues : toujours les 3 colonnes ENTIÈRES postées ensemble (voir +// db/quests/set_quest_dialogues.py) — jamais une réplique isolée. +function questSaveDialogues(quest) { + fetch('/game/' + GAME_SLUG + '/quests/' + quest.id + '/dialogues', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dialogues: quest.dialogues }), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { if (json.dialogues) quest.dialogues = json.dialogues; }); +} + +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: '' }); + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questToggleSpeaker(status, index) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + var line = quest.dialogues[status][index]; + line.speaker = line.speaker === 'joueur' ? 'pnj' : 'joueur'; + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function questUpdateBubbleText(status, index, text) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status][index].text = text; + questSaveDialogues(quest); +} + +function questDeleteBubble(status, index) { + var quest = questById(_currentQuestModalId()); + if (!quest) return; + quest.dialogues[status].splice(index, 1); + renderQuestDialogueModalBody(quest.id); + questSaveDialogues(quest); +} + +function _currentQuestModalId() { + return _questModalCurrentId; +} + +// ---------- Sélecteur de quête (assistant "+ Action" de l'éditeur de collision) ---------- +// Récupère la liste à jour (jamais présente d'avance dans scene_edit.html, +// contrairement à quests.html) puis affiche des cartes + "+ Nouvelle +// quête" DANS la modale de collision déjà ouverte (voir +// renderCollisionWizardActionStep) ; choisir/créer une quête ouvre +// ENSUITE la modale de dialogue (celle-ci empilée par-dessus), avec un +// bouton "✓ Utiliser cette quête" qui referme les deux et finalise la +// règle — exactement le comportement demandé : cliquer "Déclencher une +// quête" ouvre l'éditeur de quête, pas un simple champ texte. +function questPickerHtml(quests) { + if (!quests.length) { + return '

Aucune quête pour l\'instant.

'; + } + return '
' + quests.map(function (q, i) { + return ''; + }).join('') + '
'; +} + +var _questPickerOnUse = null; + +function renderQuestPickerStep(onUse) { + _questPickerOnUse = onUse; + var body = collisionWizardBodyEl(); + body.innerHTML = '

Quelle quête déclencher ?

Chargement…
' + + ''; + fetch('/game/' + GAME_SLUG + '/quests/list.json') + .then(function (r) { return r.json(); }) + .then(function (json) { + QUESTS = json.quests; + var list = document.getElementById('questPickerList'); + if (list) list.innerHTML = questPickerHtml(QUESTS); + }); +} + +function questPickerChoose(questId) { + openQuestDialogueModal(questId, _questPickerOnUse); +} + +function questPickerCreate() { + fetch('/game/' + GAME_SLUG + '/quests/new', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + QUESTS.unshift(json.quest); + openQuestDialogueModal(json.quest.id, _questPickerOnUse); + }); +} + +document.addEventListener('DOMContentLoaded', function () { + if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage(); +}); diff --git a/static/js/scenes/collision-rules-editor.js b/static/js/scenes/collision-rules-editor.js index c173c069..b2ed1b68 100644 --- a/static/js/scenes/collision-rules-editor.js +++ b/static/js/scenes/collision-rules-editor.js @@ -29,7 +29,10 @@ function collisionRuleTriggerSummary(rule) { } function collisionRuleActionSummary(action) { - if (action.type === 'quete') return COLLISION_ACTION_LABELS.quete + ' « ' + action.quete_id + ' »'; + if (action.type === 'quete') { + var quest = (typeof questById === 'function') ? questById(action.quete_id) : null; + return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »'; + } if (action.type === 'evenement') { var ev = CUSTOM_EVENTS_MAP[action.evenement_id]; return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »'; @@ -227,20 +230,19 @@ function collisionWizardSaveRule(action) { } // Chaque type d'action demande éventuellement une précision avant de -// pouvoir se finaliser (quête -> son id ; événement -> lequel) — "attaque" -// n'a besoin de rien de plus, se finalise directement. +// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir +// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple +// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de +// plus, se finalise directement. function collisionWizardChooseAction(type, nested) { if (type === 'attaque' || type === 'interagir') { collisionWizardFinalizeAction({ type: type }, nested); return; } if (type === 'quete') { - collisionWizardBodyEl().innerHTML = - '

Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :

' + - '
' + - '
' + - '
' + - '
'; + renderQuestPickerStep(function (quest) { + collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested); + }); return; } if (type === 'evenement') { @@ -258,13 +260,6 @@ function collisionWizardChooseAction(type, nested) { } } -function collisionWizardSubmitQuete(nested) { - var input = document.getElementById('collisionWizardQueteId'); - var queteId = input && input.value.trim(); - if (!queteId) { input.focus(); return; } - collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested); -} - document.addEventListener('DOMContentLoaded', function () { if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards(); }); diff --git a/static/style.css b/static/style.css index a158bbc6..8944360b 100644 --- a/static/style.css +++ b/static/style.css @@ -690,7 +690,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .collisionRuleCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); } .collisionRuleCardHeader{ display:flex; align-items:center; gap:10px; margin-bottom:12px; } .collisionRuleCardThumb{ - position:relative; width:40px; height:40px; border-radius:10px; overflow:hidden; background:var(--panel2); + position:relative; width:120px; height:120px; border-radius:10px; overflow:hidden; background:var(--panel2); display:flex; align-items:center; justify-content:center; flex-shrink:0; border:1px solid var(--border); box-shadow:inset 0 0 0 2px rgba(255,255,255,.03); } @@ -760,6 +760,64 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .collisionInteractBubble{ display:none; position:absolute; left:50%; bottom:100%; transform:translateX(-50%); margin-bottom:6px; background:#111; color:#fff; border-radius:10px; padding:4px 10px; font-size:12px; white-space:nowrap; pointer-events:none; z-index:20; } +/* ---- Éditeur de quêtes (templates/quests.html + modale de dialogue + partagée avec l'assistant "+ Action" de l'éditeur de collision, voir + static/js/quests/quest-editor.js) ---- */ +.questsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:18px; margin-top:14px; } +.questCard{ + background:linear-gradient(165deg, var(--panel), var(--panel2)); border:1px solid var(--border); + border-radius:16px; padding:16px; cursor:pointer; box-shadow:0 6px 18px rgba(0,0,0,.18); + animation:collisionCardIn .35s ease backwards; transition:transform .18s ease, box-shadow .18s ease; +} +.questCard:hover{ transform:translateY(-3px); box-shadow:0 10px 26px rgba(0,0,0,.28); border-color:var(--accent); } +.questCardHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; } +.questCardTitle{ margin:0; font-size:15px; font-weight:700; } +.questCardObjectif{ font-size:12.5px; color:var(--forge-text-muted, #8b95a7); margin-bottom:12px; min-height:1.4em; } +.questCardFooter{ display:flex; gap:8px; flex-wrap:wrap; } +.questPill{ display:inline-flex; align-items:center; border-radius:999px; padding:3px 10px; font-size:11.5px; font-weight:700; } +.questPill-score{ background:rgba(242,153,74,.18); color:#f2994a; } +.questPill-resultat{ background:rgba(23,162,184,.18); color:#17a2b8; } +.questPill-status{ background:var(--panel2); border:1px solid var(--border); } +.questPill-status-nouvelle{ background:rgba(155,89,182,.18); color:#9b59b6; border-color:transparent; } +.questPill-status-en_cours{ background:rgba(242,153,74,.18); color:#f2994a; border-color:transparent; } +.questPill-status-terminee{ background:rgba(46,204,113,.18); color:#2ecc71; border-color:transparent; } + +.questDialogueModalContent{ max-width:min(92vw, 1080px); } +.questDialogueModalHeader{ margin-bottom:10px; } +.questFieldsGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:10px; margin-bottom:14px; } +.questField{ display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; } +.questField-wide{ grid-column:span 2; } +.questDialogueColumns{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; overflow-x:auto; } +.questDialogueColumn{ + background:rgba(0,0,0,.15); border:1px solid var(--border); border-radius:12px; padding:10px; + display:flex; flex-direction:column; gap:8px; min-height:160px; +} +.questDialogueColumnTitle{ margin:0 0 2px; font-size:13px; text-align:center; } +.questDialogueColumnBubbles{ display:flex; flex-direction:column; gap:8px; flex:1; } +.questDialogueAddBtn{ align-self:center; padding:6px 14px; font-size:12px; } + +.questBubbleRow{ display:flex; } +.questBubbleRow-pnj{ justify-content:flex-start; } +.questBubbleRow-joueur{ justify-content:flex-end; } +.questBubble{ + display:flex; flex-direction:column; gap:4px; max-width:88%; border-radius:14px; padding:8px 10px; + animation:collisionLinkIn .25s ease backwards; +} +.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; } +.questBubbleText{ + resize:none; overflow:hidden; border:none; background:transparent; color:#fff; font-size:13px; + font-family:inherit; padding:0; min-height:1.4em; +} +.questBubbleText::placeholder{ color:rgba(255,255,255,.6); } +.questBubbleText:focus{ outline:none; } +.questBubbleDelete{ + align-self:flex-end; background:none; border:none; color:rgba(255,255,255,.6); cursor:pointer; + font-size:11px; padding:0; transition:color .15s ease; +} +.questBubbleDelete:hover{ color:#fff; } + .listRow.selected{ border-color:var(--accent); background:var(--panel2); } .childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); } diff --git a/templates/base.html b/templates/base.html index 3392f0ef..cc21362c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -53,6 +53,7 @@ une version simplifiée (juste les écrans) pour un type "restreint" (quiz/embranchement/rpg), la version complète pour "custom". --> 📊 Tableau de bord + 🗺️ Quêtes ▶️ Jouer